[Docs] (docs/Disaggregated, MG_Remote, MG_Remote/Client, MG_Remote/Protocol, MG_Remote/Server, MG_Remote/Transport, MG_Remote/Wire): shorten long comments and move their design arguments into docs

This commit is contained in:
2026-10-15 21:53:39 -04:00
parent 37ecba200b
commit 23ee8bdb49
63 changed files with 930 additions and 3343 deletions
+8 -25
View File
@@ -6,31 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P5 package w1: MGPCaps's two blob serializers, the pair MGPipeTypes.h:134-136 defers to
// this phase by name ("Their serializers land with the transport (P5)").
//
// THE FORMAT, and every part of it is a refusal rather than a guess. It is
// ProgramArtifactsCodec's shape on purpose - that codec is the tree's one worked example of a
// wire format that has survived a real transport, so copying it is cheaper than being
// original and much cheaper than being wrong:
//
// * a VERSION word first, and the two TABLE DIMENSIONS second, so a peer whose
// TextureInternalFormat enum grew is a mismatch AT READ TIME rather than a silent shear
// that reads one format's capabilities as another's;
// * length-prefixed everything, with the count checked against the bytes that REMAIN before
// a single element is reserved, so a corrupt count cannot become a four-billion-element
// resize;
// * SPARSE for all three capability tables. The dense form is
// 2 * targets * formats * 8 bytes plus the sample-count lists - with 13 targets and 77
// internal formats that is ~16 KiB of mostly zeroes, PER CONTEXT AND PER CAPS
// INVALIDATION, because R-12 makes a re-arriving snapshot the invalidation rather than a
// once-per-process cost. The tables are overwhelmingly empty, so what crosses is
// (index, value) pairs and the decoder Clear()s first;
// * little-endian by memcpy of fixed-width scalars, which is what every other MobileGL wire
// struct already assumes and what the ABI fingerprint below makes checkable;
// * every decoder returns FALSE on truncation, a bad version, a dimension mismatch, an
// out-of-range index or TRAILING BYTES THE FORMAT DOES NOT ACCOUNT FOR. These bytes
// arrive over a wire and the outputs are left in a defined, default state on a refusal.
// MGPCaps's two blob serializers: the format capability cache and the renderer info. Each rule
// of the format refuses rather than guesses. A version word and the two table dimensions come
// first, so a peer whose format enum grew fails at read time instead of shearing one format's
// caps onto another. Every count is length-prefixed and checked against the remaining bytes
// before anything is reserved. The three capability tables cross sparse as (index, value) pairs:
// dense they are ~16 KiB of mostly zeroes, resent with every snapshot. Scalars are little-endian
// by memcpy. Decoders return false on truncation, a bad version or dimension, an out-of-range
// index or trailing bytes, and leave the outputs in a default state.
#include "CapsCodec.h"
+7 -26
View File
@@ -32,33 +32,14 @@
namespace MobileGL::MG_Remote {
// ---- CallMask's layout (c0's ruling, extending R-8) ---------------------------------
//
// R-8 requires the client's liveness gates - MGPipeResourceSubsystemEnabled() and
// P4aFamilyHasItsConsumer() - to answer from MGPCaps::CallMask instead of from
// MGPipeGetResourceOps(), because that op table is the SERVER's registration: under
// inproc a client reading it is right by accident, and under spawn it is null and five
// whole record families silently emit nothing.
//
// But CallMask as declared carries only the nine MGPCapBit FEATURE bits
// (MGPipeTypes.h:108-124) and has no per-family bit at all, so "read the CallMask" was
// not yet an implementable instruction. It is now:
//
// bits 0..8 MGPCapBit, unchanged. kCapViewportArray (0), kCapNeedsHostIndexBytes (7)
// and kCapNeedsHostUboBytes (8) are RESERVED (P8-F): never published, and
// their positions are never reused, which keeps every MGHostSpan off the wire.
// bits 9..31 reserved for further MGPCapBits.
// bits 32..47 THE CONSUMER MASK: bit (32 + n) means "the server has a consumer for
// MGPipe subsystem bit n" - i.e. the server's own subsystem mask, shifted.
// Sixteen bits covers bits 0..12 allocated through P4a with room to P8.
// ---- CallMask's layout ---------------------------------------------------------------
// The client's consumer gates (MGPipeResourceSubsystemEnabled, P4aFamilyHasItsConsumer)
// read CallMask, never MGPipeGetResourceOps(): that op table is the server's registration.
// bits 0..31 MGPCapBit. Reserved bits (kCapViewportArray, kCapNeedsHost*Bytes) are
// never published and their positions are never reused.
// bits 32..47 consumer mask: bit (32 + n) = the server consumes MGPipe subsystem bit n.
// bits 48..63 reserved.
//
// protocol.fbs:94's `tableSlotMask: ulong` is DELETED rather than renamed (R-8 offered
// either). Two reasons, and the second is decisive: ARCHITECTURE.md §3.3 says CallMask
// REPLACES "is this table slot null" as the capability probe, so a field whose comment is
// "which GLFunctionsTable slots the peer registered" re-introduces exactly what it
// replaced; and GLFunctionsTable has SIXTY-NINE function-pointer slots
// (BackendObject.h:117-292), so a 64-bit mask cannot address it and never could.
// There is no table-slot mask: CallMask replaces the null-slot probe (ARCHITECTURE.md §3.3).
inline constexpr Uint32 kMGCapsConsumerBitShift = 32;
inline constexpr Uint64 kMGCapsConsumerMask = 0xFFFFull << kMGCapsConsumerBitShift;
@@ -33,30 +33,12 @@ namespace MobileGL::MG_Remote::Client {
// ---- the EGL seam ------------------------------------------------------------------
//
// THE EGL VIRTUALS CALL v1's TWELVE FORWARDERS AND NOTHING ELSE (the swap interval's
// since P10 through the emit table's slot, not an override here). c1 round 1 built
// its own trampolines over ServerLoop's control channel 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 pack a SurfaceControlFrame and block on mgl-srv-apply themselves (P5f
// fc; the channel was a function-pointer mailbox before that) and run INLINE when the
// caller is already on that thread, so this file needs no frame, no per-call args
// struct, and no null-backend check of its own - the dispatch's null-backend arm is the
// one place that answers "the bring-up did not complete", and it answers `false` rather
// than crashing or succeeding locally.
// The EGL virtuals call the Server* forwarders (ServerLoop.h) and nothing else, because
// some do more than forward: ServerMakeEGLCurrent and ServerInitCapabilities republish
// the caps snapshot after the server's InitCapabilities, and ServerSetWindowHandle hands
// the surface to the server's backend; calling the backend directly would leave the caps
// mirror on the pre-context snapshot. The forwarders block on mgl-srv-apply (inline when
// already on it) and answer false when the bring-up did not complete.
// 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
@@ -6,45 +6,14 @@
// 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 ServerLoop's control-frame channel (P5f fc; v1's
// function-pointer mailbox before it), and then lets the
// base class keep the client-side books.
//
// SetEGLSwapInterval IS NOT OVERRIDDEN (P10 B). It used to be, because the base implementation
// calls GetBackendFunctions().SetSwapInterval (BackendObject.cpp:402) and that slot was the last
// class-C Fatal in the emit table. The slot is now the forwarder the override called
// (Server::ServerSetEGLSwapInterval, EmitTables.cpp), so the base's route IS the forward and a
// second spelling of it here would only be a second place for the two to drift apart.
// The client role's BackendObject: no context, driver or GL state. Caps come from CapsMirror,
// the function table is the remote emit table, and EGL work is forwarded to the server.
// Trap 1: GetRendererInfo() returns a reference, so CapsMirror owns it (a placeholder until the
// first snapshot). Trap 2: GetFormatCapabilities() is not virtual, so each adopted snapshot is
// pushed into m_formatCapabilities. Trap 3: GetBackendType() answers the server's backend; there
// is no Remote value, because callers switch on it and an unknown value takes a wrong arm.
// Each EGL override runs the real work on the server, then lets the base class keep the client's
// books (so SwapEGLBuffers reaches Present). SetEGLSwapInterval's table slot already forwards.
#pragma once
#include <Includes.h>
+12 -38
View File
@@ -6,27 +6,12 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// 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.
//
// 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.
// The read accessors answer before the first snapshot instead of aborting, so an early read
// such as LogBackendInfo's startup logging cannot keep the process from starting. They return
// the default-constructed members, which Adopt() replaces in place (the accessors return
// references, so the storage outlives every caller), and a read of the placeholder logs a
// warning. ServerConsumes() is the exception and never answers from a placeholder; see
// RequireFirstSnapshot.
#include "CapsMirror.h"
@@ -175,23 +160,12 @@ namespace MobileGL::MG_Remote::Client {
}
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 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.
// The only client-side source of this answer. MGPipeGetResourceOps() is the server's
// registration: right by accident inproc and null under spawn, where reading it would
// silently stop whole record families while the client clears its dirty flags. The bit
// layout is read through CapsCodec.h's helper so both sides share one spelling. A
// placeholder mirror does not answer: the client has no pull path, so withholding would
// drop records. The decision waits for a real snapshot or fails naming the family.
if (m_generation == 0) {
RequireFirstSnapshot(subsystemBit);
}
+15 -47
View File
@@ -6,34 +6,14 @@
// 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).
// The client's copy of the server's capabilities. Every client-side caps read is answered
// locally, with no record on the wire, and several readers bind a reference and read many
// members, so the mirror holds the whole snapshot. The accessors return references, so the
// mirror owns the storage; before the first snapshot it answers a placeholder rather than abort.
// GetFormatCapabilities() is non-virtual, so the remote backend fills
// BackendObject::m_formatCapabilities from here (SetCapsAdoptedHook).
// Invalidation is re-arrival: the server re-sends the whole snapshot on every InitCapabilities
// run, and Generation() is what a client-side memo keys on.
#pragma once
#include <Includes.h>
@@ -86,25 +66,13 @@ namespace MobileGL::MG_Remote::Client {
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.
// ServerConsumes() decides whether a record family reaches the wire, so it never answers
// from the placeholder: a zero mask would drop the family's records while the emitter
// clears their dirty flags, and the client has no pull path to fall back to. This waits,
// bounded by kFirstSnapshotWaitMs, for a handshake in flight on another thread, else it is
// Fatal{CapsBeforeFirstSnapshot, "<family>"}. It never waits on the thread running the
// handshake, which would deadlock; MobileGL::Initialize brings the split roles up before
// MG_State::Init() so no frontend object asks that early.
void RequireFirstSnapshot(Uint64 subsystemBit) const;
MG_Pipe::MGPCaps m_caps{};
+42 -142
View File
@@ -167,22 +167,13 @@ namespace MobileGL::MG_Remote::Client {
::std::atomic<int> g_bringUpsInFlight{0};
thread_local Bool tl_bringingUpHere = false;
// ---- P7 CI: THE COLD-START BUDGET OF A SURFACE-CONTROL REPLY -----------------------
// ---- cold-start budget of a surface-control reply ----------------------------------
//
// A spawned (or tcp) server brings its NATIVE backend up lazily, INSIDE the first
// surface creation (Espryt: eglInitialize under CreatePbufferSurface/CreateWindowSurface)
// or the first MakeCurrent (Magma: the Vulkan instance and device) - ServerLoop.cpp's
// ApplySurfaceControlFrame. On a workstation that is ~100 ms; on a loaded CI runner it
// took longer than the 5 s steady-state bound, and the retrace-split spawn legs died on
// "no SurfaceReply for CreatePbufferSurface seq 2" / "for MakeCurrent seq 3" with the
// server still alive and about to answer (11 of 77 legs in run 35671704873, 17 of 77 in
// 35706183230, a different set each run). The server was never wedged; the client simply
// asked a cold software rasteriser for a warm one's latency.
//
// So until the session's first MakeCurrent is answered ok, those three ops wait the
// cold-start budget; every other op, and all three once the backend is warm, keep the
// steady bound. The budget is never shorter than the steady bound, and its expiry is
// the same non-fatal, NAMED answer (D5b) - a longer wait, not a different contract.
// The server brings its native backend up lazily inside the first surface creation
// (Espryt's eglInitialize) or the first MakeCurrent (Magma's Vulkan instance and device;
// ServerLoop.cpp's ApplySurfaceControlFrame). On a loaded CI runner that outlasts the
// steady bound, so until a MakeCurrent is answered ok these three ops wait
// max(steady, ColdStartMs). Expiry is the same non-fatal named answer as any timeout.
Bool MayBringTheBackendUp(Server::SurfaceControlOp kind) {
return kind == Server::SurfaceControlOp::CreatePbufferSurface ||
kind == Server::SurfaceControlOp::CreateWindowSurface ||
@@ -393,25 +384,13 @@ namespace MobileGL::MG_Remote::Client {
return "?";
}
// ---- c1: the reverse channel's reading end ---------------------------------------
// ---- 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).
//
// THE CONSUMERS ARE CALLED BY NAME, NEVER THROUGH gMGPipeCallbacks (CONTRACT-P5C
// §4.1): with an active transport the global table's reverse entries are the SERVER
// session's producer callbacks, so routing the drain through it would call the
// producer back from the GL thread - the layer-2 violation the contract names.
//
// THE SURFACE-CHANGED CONSUMER IS WHERE R3's OWNERSHIP ALWAYS WAS: the backend
// posts the MGPSurfaceInfo, and the allocate/format writes against
// pDefaultFramebufferInfo happen HERE, on the GL thread, against client memory.
// The GL thread drains SEG_EVENT between verbs and inside client waits. The payloads live
// in the ring, so Drained() runs only after every popped payload has been consumed.
// Consumers are called by name, never through gMGPipeCallbacks: with an active transport
// its reverse entries are the server session's producer callbacks. The surface-changed
// consumer applies the server's MGPSurfaceInfo to pDefaultFramebufferInfo in client memory.
void ApplySurfaceChangedToClient(const MG_Pipe::MGPSurfaceInfo& info) {
auto& defaultFBOInfo = MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo;
if (!defaultFBOInfo) return;
@@ -1955,25 +1934,14 @@ namespace MobileGL::MG_Remote::Client {
}
}
// ---- 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
// the whole kBarrierTimeoutMs 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.
// ---- 9. Install the wire emitters last; each earlier step is a precondition:
// - after Hello/Welcome, or an emitter would publish into a ring the peer has not mapped;
// - after the first CapsSnapshot: liveness gates read the caps mirror, and a placeholder
// mirror says the server consumes nothing;
// - after the inproc ServerLoop::Start, or EmitAndWait would wait on an idle appliedSeq;
// - on the GL thread that called MG_Backend::Init(), the one that may write gPipeInputs.
// No fence is needed: the apply thread never reads these tables, and this thread wrote
// them before it can reach any GL entry point.
InstallClientWireTables();
return MOBILEGL_OK;
}
@@ -2307,23 +2275,13 @@ namespace MobileGL::MG_Remote::Client {
// will not read this one (resource_subdata's buffer half, §2.5 - the only such caller).
// The server posts either way, so the wire is unchanged and the row keeps its flag.
// ---- THE P5e WAIT RULE (CONTRACT-P5E §2.3) ---------------------------------------
// ---- run-ahead wait rule ---------------------------------------------------------
//
// IT STANDS BEFORE THE BATCH SKIP AND REPLACES IT WHOLE, rather than adding a clause to
// it. The batch skip is a rule about CALL CLASSES ("a set/CSO/object record carries its
// whole value in the payload"); the wait class is a rule about WHAT AN APPLY READS, and
// it is generated from the same PipeCalls.def row the class comes from
// (MGPipeWaitClassFor, c0e). Two rules layered would mean a row could be skipped by the
// class rule and waited by the column, and the column is the one the SERVER's
// MGPipeBarriered reads - so the two halves of the wire would disagree about whether
// the client is parked behind this record, which is the single fact §4.4's exemptions
// and §3.3's detector both rest on.
//
// WITH RUN-AHEAD DISARMED NOTHING BELOW RUNS and the file's original shape continues -
// Magma, a monolith transport, MOBILEGL_IPC_RUN_AHEAD=0 and every build before the
// integration commit take the old path byte for byte, which is what makes RUN_AHEAD=0
// a pure wait-rule A/B on identical server code (§5.8).
// With run-ahead armed, the WaitClass column (MGPipeWaitClassFor) alone decides whether a
// record waits, and the batch skip below is disarmed. The server's MGPipeBarriered reads
// the same column, so a second skip rule would let the two sides disagree about whether
// the client is parked behind the record (ARCHITECTURE.md §11.6.1). With run-ahead
// disarmed this block is skipped, so MOBILEGL_IPC_RUN_AHEAD=0 is a pure wait-rule A/B.
if (RunAheadArmed() && !ownsReplySlot) {
MG_Pipe::MGPipeWaitClass waitClass = MG_Pipe::MGPipeWaitClassFor(op);
// §2.5's named relaxation, and the ONLY place a static column answer is overridden:
@@ -2357,41 +2315,13 @@ namespace MobileGL::MG_Remote::Client {
}
}
// MOBILEGL_IPC_BATCH_WAITS (default 1): a value-class record is published WITHOUT
// waiting for its own apply. The barrier's one load-bearing reader is the backend
// sync that pulls the object-class fields (CONTRACT-P5 table 2's remaining rows), and
// those reads happen only at the kCtxVerb applies - draw / clear / blit / dispatch /
// readback / XFB - plus the screen and query classes, and EVERY ONE OF THOSE STILL
// WAITS below (as does every reply-slot row, whose answer the client consumes). So
// the pulled fields are exactly as fresh at every read point as the per-record
// barrier made them: between two waits the residual fill may move a field, but no
// apply is reading it in between, and the next pull-verb's fill runs after the
// previous pull-verb's apply was waited - the R-1 fence posts are untouched, only
// the number of round trips between them changes. Set / CSO / object records carry
// their whole value in the payload and are consumed at the next (waited) sync, which
// the in-order ring guarantees precedes that sync's apply.
//
// WITH ONE NAMED EXCEPTION, FOUND BY ITS OWN FATAL (P5d round 3): generate_mipmap is
// catalogued kCtxObject, but its sink is the backend's GenerateMipmap, which reads
// gPipeInputs->GetActiveTextureUnit() and the unit's binding slot - residual inputs whose
// freshness is the CLIENT's verb serial. Published without a wait, the record sits in
// the ring while this thread runs the next verb's MGPipeValidateForVerb, which moves
// that serial and withdraws the server stamp; the apply thread then reads the unit
// stale and aborts Fatal{UnmigratedPipeInput, "GetActiveTextureUnit@<next verb>"}
// (F1WireScenario.GenerateMipmap*Pixels, reproduced on the gate tree once the round-3
// spin no longer read the clock). So it waits, exactly as the four stamping verbs
// do. The rule this encodes: a record may skip its wait only if its apply reads
// nothing the residual fill writes - the value carried in the payload is the whole
// input. No other kCtxObject row has a backend body behind it that reads gPipeInputs
// (set_texture_params, resource_subdata, resource_readback and get_texture_image
// own reply slots and wait anyway; the resource_* transfers and object_death resolve
// by handle).
//
// AND IT IS DISARMED WHOLE UNDER RUN-AHEAD (P5e §2.3): the wait class above is the
// rule there, and a second skip rule reading a different column is how the two halves
// of the wire end up disagreeing about whether the client is parked. Written as a
// conjunct here rather than left to "the classes happen not to overlap", because the
// classes overlapping is one PipeCalls.def row away at any time.
// MOBILEGL_IPC_BATCH_WAITS (default 1), lockstep only: a state, CSO or object record is
// published without waiting for its apply. Its payload is its whole input, and the backend
// reads pulled fields only in verb, screen and query applies, which still wait, so each
// read sees the values the per-record barrier would give. A record may skip its wait only
// if its apply reads nothing the residual fill writes: generate_mipmap is an object record
// but GenerateMipmap reads the active texture unit from gPipeInputs, so it waits.
// Disarmed under run-ahead, where the WaitClass rule above is the only rule.
if (!ownsReplySlot && !RunAheadArmed() && MG_Config::Ipc.BatchWaits != 0 &&
op != MG_Pipe::MGPWireOp::GenerateMipmap) {
const MG_Pipe::MGPipeCallClass callClass = MG_Pipe::MGPipeCallClassFor(op);
@@ -2770,29 +2700,11 @@ namespace MobileGL::MG_Remote::Client {
//
// This is where a fill left standing for an unbarriered record lands, by name.
if (ClientSessionInstance().RunAheadArmed()) {
// ---- P5e (ra2): THE EXEMPTION IS A FACT NOW, NOT A CLAIM ------------------------
//
// This arm used to read `if (isBarrieredFill) return;`, i.e. the caller's own
// sentence "this touch is the residual fill of a record this thread is about to park
// behind" was accepted as the whole argument. It is an argument about the FUTURE.
// The order at the validate point is fill, then emit, then park, so at the instant
// of the write the apply thread is still draining the unbarriered records the client
// ran ahead of - and the measured consequence was the GL thread bumping
// CurrentVerbSerial, withdrawing MGPipeServerClearVerbBoundary's flag and renaming
// m_currentVerb underneath a record the applier was inside, which surfaced as
// Fatal{UnmigratedPipeInput, "<field>@<the client's verb>"} on the apply thread.
// Because the exemption was unconditional, this guard could not fire on any of it:
// that is the answer to "does RefusePipeInputsTouchWhileApplierOwnsIt fire, and if
// not, why not".
//
// So the sentence still exempts the fill, but only once it is TRUE of this instant.
// The fill sites establish it by taking §2.5's forced wait first
// (QuiesceApplierBeforeFill, PipeFill.cpp); removing that wait is the red-once and
// lands here, by name, on the first barriered verb behind a run-ahead backlog.
//
// ApplyThreadIsInsideApplier() is an acquire load of the flag PipeApplier raises for
// the whole of ApplyOne - stamp, decode and LeaveApplier included - so "not inside"
// is exactly "no record is being applied" and not merely "not decoding".
// A barriered fill is exempt only if the applier is idle at this instant: the validate
// point runs fill, emit, park, so the applier may still be applying records the client
// ran ahead of. The fill sites wait for it first (QuiesceApplierBeforeFill), and
// ApplyThreadIsInsideApplier() covers the whole of PipeApplier's ApplyOne, so "not
// inside" means no record is being applied (ARCHITECTURE.md §9.2.2).
if (isBarrieredFill && !ApplyThreadIsInsideApplier()) return;
if (Server::ServerLoop::OnApplyThread()) return; // the applier owns the block
// P15: with two blocks the GL thread's fill writes the client block, which the
@@ -3305,22 +3217,10 @@ namespace MobileGL::MG_Remote::Client {
Transport::LogRoleMemory(phase, SampleMemory());
}
// R-10's AND R-9's numbers IN EVERY SPLIT PRIVATE LOG, not only in the lanes that set
// MOBILEGL_PIPE_STATS=1.
//
// WHY IT IS HERE AND NOT ONLY ON THE STATS LINE. `MGPipe stats:` is an opt-in channel: two
// ctest entries out of 21 set MOBILEGL_PIPE_STATS, and neither the retrace lanes nor the
// 19 ordinary split entries do. R-10's proof obligation is about THE PHASE, not about the
// two counting lanes - "no record on the reduced path comes near half the ring" has to be
// readable from any split run that happened, which is what ID-53's per-entry private log
// is for. One line per session teardown costs nothing and cannot be missed.
//
// IT IS ALSO WHERE THE PROOF FAILS SOFTLY. A record ABOVE the cap already aborts on the
// spot with Fatal{RingOverrun} (PipeWireCodec.cpp), so this line's job is the other half:
// a maximum that is merely CLOSE to the cap is not a crash and would otherwise be
// invisible until the day a workload crossed it. The percentage is printed for exactly
// that reason, and R-10 names the integrator as the person who decides between early
// chunking and a bigger default ring when it climbs.
// Logs the wire ledger once per session teardown at INFO, so every split run's log shows the
// largest record against the ring's record cap whatever MOBILEGL_PIPE_STATS says. A record
// above the cap already aborts with Fatal{RingOverrun} (PipeWireCodec.cpp); the percentage
// shows a maximum creeping toward it, which calls for earlier chunking or a bigger ring.
void ClientSession::LogWireLedger() const {
const Uint64 maxRecord = m_encoder.MaxRecordBytesSeen();
const Uint64 cap = m_encoder.MaxRecordBytesCap();
+38 -116
View File
@@ -6,31 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The client half of a session: the rings, the handshake, the verb barrier. Owner: package s1
// (construction and handshake) with c1 (the barrier and the reply read). Signatures by c0.
//
// INPROC USES ShmSegment AND THE RING, NOT new[] AND NOT InProcessTransport's deques. That is
// half of what "inproc runs the same G3 codec as spawn" means: InProcessTransport
// (InProcessTransport.cpp:38-97) is two deque<vector<uint8_t>> plus two condvar doorbells, it
// touches neither a ring nor a codec, and building the session on top of it instead of on top
// of the ring would make the whole phase unfalsifiable. The transport supplies the two
// DOORBELLS and the control plane; the records go through SEG_CMD.
//
// THE VERB BARRIER (R-1). After emitting a verb the client blocks until
// RingControl::appliedSeq >= the seq it just got back from the encoder. It is not caution: 31
// of the 63 PipeInputs fields are still filled by the client's residual pass out of a live
// GLContext, so an unbarriered queue lets the server read a FUTURE value of them. Two
// consequences that must be stated because both are load-bearing:
// - while the barrier holds, at most one of {GL thread, apply thread} is runnable, which is
// what makes a single process-wide gPipeInputs legal (table 3);
// - the barrier is a RETIRING object, not a design. It opens family by family as table 2's
// BARRIER-PULLED column empties, and each later package reports how many rows it left.
//
// THE BARRIER'S WAIT IS ALSO THE REPLY'S WAIT (R-3/R-5). The reply slot id IS the record seq,
// so "wait for appliedSeq >= mySeq" and "wait for my answer" are one wait and the four Bool
// acceptance returns, ReadPixels' pixels and MapPersistent's decline cost ZERO extra round
// trips. The client MUST NOT re-derive any of those four answers locally - that is the c0f/c0g
// defect P4a paid two contract corrections for, and "always accept" is ID-39's 66 lost uploads.
// The client half of a session: the rings, the handshake and the record wait rule
// (ARCHITECTURE.md §11.6). Inproc uses the same shared segments, ring and codec as spawn;
// InProcessTransport supplies only the two doorbells and the control plane, so the inproc lane
// exercises the real data path. The reply slot id is the record seq, so waiting for
// appliedSeq >= seq is also the wait for the answer and a reply costs no extra round trip.
// An answer the server may decline is read from its reply, never assumed: a client that assumed
// "accepted" would clear its dirty flags for records the server dropped. Rows that skip the
// answer (willReadReply=false) do so only where the client already knows it or nobody uses it.
#pragma once
#include "../Transport/ILink.h"
@@ -73,26 +56,14 @@ namespace MobileGL::MG_Remote::Client {
// Null until Start() succeeds; MG_Backend::Init() is the only caller of Start().
static ClientSession* Active();
// P6 `dl` (CONTRACT-P6 5.3). THE DEVICE-LOST LATCH, and the two words that matter are
// SESSION-SCOPED and LATCH: it is set at most once per session and never cleared while
// that session lives. What recovers is the PROCESS, not the session: the application's
// next eglCreateContext / eglCreate*Surface / eglInitialize ends the lost session and
// dials a fresh one (RecoverAfterDeviceLoss), and every context built before stays lost.
//
// SET FROM Doorbell::PeerHungUp() OR THE SERVER'S OWN SessionFault, NEVER FROM A
// TIMEOUT. 5.4's whole point is that slow and dead are different states and must be told
// apart without a threshold: under P5e run-ahead a server one frame behind is the
// INTENDED steady state, so a latch armed by a deadline would fire on a healthy session
// under load. The descriptor (or the server, naming its fault) answers instead.
//
// WHAT THE CALLING THREAD SEES, which is more than the latch: the thread's GL state may
// belong to a session that has already been replaced (a context from before a recovered
// loss, still current on some thread), and for that thread the device is lost for good -
// nothing it does may reach the new session. ScopedWireMute below answers true as well;
// ScopedCurrentSessionWork lifts the thread's own staleness (not the latch).
//
// The static form answers false whenever no session is active, so monolith and the pull
// build get the honest answer with no branch of their own.
// Whether the device is lost for the calling thread. The latch is per session: set at
// most once, never cleared while that session lives. Recovery replaces the session
// (RecoverAfterDeviceLoss) and every context built before stays lost (ARCHITECTURE.md
// §15.3). It is set only when the peer hangs up or the server sends a SessionFault, never
// from a timeout: under run-ahead a server a frame behind is healthy. Also true inside
// ScopedWireMute and on a thread whose context belongs to a replaced session;
// ScopedCurrentSessionWork lifts the thread's staleness, not the latch. Otherwise false
// when no session is active (monolith).
static Bool DeviceLost();
// The latch alone: is the ACTIVE session lost. What the EGL layer asks before recovering.
static Bool SessionLatchedLost();
@@ -184,32 +155,13 @@ namespace MobileGL::MG_Remote::Client {
// ran monolith and went green" failure, and it must be loud.
MobileGLResult Start(MG_Config::TransportMode mode, const String& endpoint);
// Start()'s second half: the handshake and everything after it, over a transport pair
// the CALLER made with InProcessTransport::CreatePair. Start() refuses every mode but
// `inproc`, makes the pair, and calls this; it is public for exactly one reason. The
// Welcome guard below (envelope->msg_as_Welcome() == nullptr, ID-46 finding 7) can only
// be reached by a frame that arrives on the server->client direction BEFORE the
// server's own Welcome, and Start() builds that pair itself, so no control could put
// one there. SessionHandshakeTest does it through here, and the null-union Welcome must
// come back as MOBILEGL_ERR_PROTOCOL_MISMATCH with the guard's own line. Not a second
// way to start a session: MG_Backend::Init() calls Start(), and nothing else may call
// this with a pair it did not just create.
// P6 `sm`: the spawn client's half. `transport` is this process's end of
// the control socket to an already-running server process; the two bell
// fds are this side's ends of the two bell pairs (ServerSpawn.h).
//
// It is the SAME handshake StartOverTransportPair runs, with three
// differences and no others: the server half happens in another process
// so there is no local Accept to call, the four segments arrive as
// descriptors over SCM_RIGHTS instead of being dup'ed from a local
// owner, and the bells are sockets rather than condvars. Everything
// below the attach - producer, reply pool, event ring, segment table,
// encoder - is byte for byte the inproc path, which is the property
// that makes "P6 is a transport swap" true of THIS function even though
// it is not true of the phase.
// `cp`: the remote control sink ServerLoop calls when the server is
// another process. Encode, send, wait for the SurfaceReply that carries
// OUR seq back.
// The sink ServerLoop calls when the server is another process: it encodes the frame,
// sends it and waits for the SurfaceReply carrying its seq.
// StartOverTransportPair (below) is Start()'s second half, over a pair the caller just
// made with InProcessTransport::CreatePair. It is public only so SessionHandshakeTest can
// put a frame ahead of the server's Welcome; production reaches it only through Start().
// StartSpawned and StartOverSocket run the same handshake against a server in another
// process.
static MobileGLResult RemoteControlSinkThunk(void* user, Server::SurfaceControlFrame& frame);
MobileGLResult RunRemoteSurfaceControlFrame(Server::SurfaceControlFrame& frame);
@@ -321,23 +273,14 @@ namespace MobileGL::MG_Remote::Client {
// DECLINED rather than a hang.
static void SetT0OfferFaultForTest(Int32 delayMs);
// `willReadReply` (P12) and `wantReply` ARE TWO QUESTIONS AND MUST STAY TWO.
//
// wantReply does THIS CALL read the answer (it blocks on the barrier and takes the
// reply slot before returning);
// willReadReply will the answer be read AT ALL - now, or later by somebody else.
//
// The create window is the case that makes the difference load-bearing: it emits with
// wantReply=false and its answers are read afterwards by the window's drain, so a record
// it emits must still be ANSWERED. Only willReadReply=false tells the server it may skip
// the answer (Transport::kRecNoReply), and only three sites may say it: the parameter row
// for a confirmed object, the respecify row's fire-and-forget half, and the sub-data row
// - each of which answers its own caller locally and reads nothing from the wire.
//
// THE TWO ILLEGAL COMBINATIONS ARE FATAL BY NAME rather than ignored, because both would
// fail silently in opposite directions: wantReply && !willReadReply is a caller blocking
// on an answer it has just told the server not to send, and !willReadReply on a row with
// no reply slot is a caller discarding an answer that never existed.
// EmitAndWaitTails' `wantReply` and `willReadReply` are separate questions:
// wantReply this call blocks and reads the answer before it returns;
// willReadReply someone reads the answer at all, now or later (the create window emits
// with wantReply=false and its drain reads the answers afterwards).
// Only willReadReply=false lets the server skip the answer (Transport::kRecNoReply); the
// rows in WireTables.cpp that pass it each answer their caller locally. wantReply with
// !willReadReply, and !willReadReply on a row with no reply slot, are Fatal by name: the
// first would wait for an answer never sent, the second discards one never posted.
// 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.
@@ -431,34 +374,13 @@ namespace MobileGL::MG_Remote::Client {
static Bool InBarrierWait();
static Bool ApplyThreadIsInsideApplier();
// P5c (gt, CONTRACT-P5C §6 layer 2 / audit A1): the sentence above's second half,
// wired. A GL-thread touch of gPipeInputs while the apply thread is inside the
// applier and THIS thread is not in a barrier wait is Fatal{RoleViolation,
// "gPipeInputs"} - the barrier is the only thing that makes one process-wide
// gPipeInputs legal (CONTRACT-P5 table 3), and a touch in that window races the
// applier's own reads of it. The residual fill and the verify harness are legal by
// timing, not by exemption: they run before the record is published, which under an
// armed barrier is a moment the flag is provably down (the applier drops it before
// appliedSeq advances past the record the client last waited on). No-op when no armed
// split session is live, when the caller IS the apply thread, and when the barrier is
// disarmed - MOBILEGL_IPC_VERB_BARRIER=0 is R-1's negative control and its red belongs
// to Fatal{BarrierViolation}, not to this check.
//
// P5e (ra, CONTRACT-P5E §3.5) RE-DERIVES IT UNDER RUN-AHEAD, and `isBarrieredFill` is
// what the caller says about ITSELF: "this touch is the residual fill of a record this
// thread is about to park behind". With run-ahead armed the block is server-role
// memory for every other touch. The MOBILEGL_IPC_BATCH_WAITS early return goes with
// it: batching is about how many round trips a lockstep client pays, and it says
// nothing about a client that pays none.
//
// P5e (ra2) KEEPS THE ARGUMENT AND STOPS TAKING IT ON TRUST. "About to park behind it"
// is a claim about the future; the write is now. Between the fill and the park the
// apply thread is still draining the unbarriered records the client ran ahead of, so an
// unconditional exemption made this guard unfireable on exactly the class it was
// written for - measured as the apply thread aborting on a field whose stamp the GL
// thread had just withdrawn. So the rule is "not while the applier is inside" again,
// for barriered fills too; what changed is that the fill sites MAKE it true first, with
// §2.5's forced wait (PipeFill.cpp's QuiesceApplierBeforeFill), instead of asserting it.
// Fatal{RoleViolation, "gPipeInputs"} when a GL-thread touch of gPipeInputs could race the
// applier's reads (ARCHITECTURE.md §9.2.2). Under run-ahead only a barriered fill
// (`isBarrieredFill`) made while the applier is not inside a record is legal, so fill
// sites wait first (QuiesceApplierBeforeFill); with distinct client and server blocks a
// barriered fill is always legal. Under lockstep it checks only with
// MOBILEGL_IPC_BATCH_WAITS=0 and an armed barrier, and fires when the applier is inside
// and this thread is not in a barrier wait. Never fires on the apply thread.
static void RefusePipeInputsTouchWhileApplierOwnsIt(const char* surface,
Bool isBarrieredFill = false);
+21 -85
View File
@@ -6,26 +6,12 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// 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 54 slots emitted; class C 15 slots name their unmigrated verb.
// 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.
//
// P5b MOVES SLOTS FROM C TO B, ONE PACKAGE AT A TIME (docs/archive/contracts/CONTRACT-P5B.md §7). The three
// numbers above are the partition AT THE P5b CONTRACT COMMIT and they are the ones the contract
// states; the arithmetic below is what the tree currently has, and the per-package ownership
// assertions say which package moved which slot. On this head t2 has landed: class B is 5 + 6
// and class C is 58.
//
// 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.
// The remote emit table: one slot per function pointer of GlobalBackendFunctionsTable
// (kRemoteEmitSlotCount). Class A slots are answered locally from the caps mirror, class B slots
// emit a record, and class C slots abort naming their unmigrated verb. The class counts are
// static_asserted to partition the table, so a slot that changes class without updating the
// counts breaks the build. Pre-verb hooks (VerbPort.h) run before the record, never after: they
// publish coherent-map bytes and the GPU-write set for the work the record is about to start.
#include "EmitTables.h"
#include <MG_Remote/FatalFunnel.h>
@@ -163,22 +149,10 @@ namespace MobileGL::MG_Remote::Client {
ClientSession& session = RequireSession("Present");
BeforeReadOnlyVerb();
// ---- R-10's and R-9's readings, published BEFORE the present record ------------
//
// THE FRAME BOUNDARY IS THE RIGHT PLACE and the per-record path is the wrong one:
// the encoder keeps all five as run totals, so this is five relaxed stores per
// frame rather than five per record. Guarded by Enabled() like every other counting
// site in the tree, so the cost with MOBILEGL_PIPE_STATS unset is a global load and
// a predicted branch.
//
// AND IT IS BEFORE THE EmitAndWait BELOW, WHICH IS NOT A DETAIL. PipeStats::OnPresent
// is called by the SERVER's Present - i.e. from inside the apply of the very record
// this function is about to emit - so a publish placed after it lands one frame
// late, and the FIRST summary line of every run then reads `maxrec=0 maxcap=0`.
// Measured that way once: a zero that means "not published yet" is printed in the
// same shape as a zero that means "nothing crossed", and the second one is a real
// defect (an emit table that fell through to the driver). The Present record is 24
// bytes and cannot be the maximum, so nothing is lost by reading one record early.
// Publish the encoder's run totals at the frame boundary (five stores per frame, not
// per record) and before the present record: PipeStats::OnPresent runs in the server's
// apply of that record, so publishing after it would make each run's first summary
// line read zero. The present record is too small to be the maximum.
if (MG_Util::PipeStats::Enabled()) {
const Wire::PipeWireEncoder& encoder = session.Encoder();
using MG_Util::PipeStats::Gauge;
@@ -398,32 +372,11 @@ namespace MobileGL::MG_Remote::Client {
Uint64 replyBytes = 0;
session.EmitAndWait(op, payload, bytes, nullptr, 0, &result, sizeof(result),
&status, &replyBytes);
// A DECLINED SYNC REPLY IS AN ANSWER, NOT A CORRUPTION (P12). ReplySink opens the
// three-value space with "DECLINED IS A REAL ANSWER, not a failure"
// (PipeWireCodec.h:457-460) and the escape rows are held to it the other way round -
// "ERROR IS NOT A DECLINE", an escape may not fold 2 into false
// (WireTables.cpp:446-450). This site folded 1 AND 2 into one abort.
//
// BOTH VALUES OF 1 ARE REACHABLE WITHOUT A BYTE OF CORRUPTION:
// - the SERVER declines these two rows by design whenever it cannot answer them -
// PostReply(op, seq, ok ? kStatusOk : kStatusDeclined, ok ? &result : nullptr,
// ok ? sizeof(result) : 0) at PipeWireCodec.cpp:1820-1830, which is a legitimate
// zero-length answer to a kWaitReply row (R-5);
// - the CLIENT produces the same status itself once the session stops carrying
// records: the device-lost latch (ClientSession.cpp:1837) and a doorbell that died
// during teardown (ClientSession.cpp:2025, where the comment says outright that
// "the verb did not happen" is reported as DECLINED and not as ERROR).
//
// AND THE NO-OP IS THE ONE THIS FRONTEND ALREADY GIVES for a fence it cannot wait on:
// GL_Sync.cpp:103-106 answers GL_ALREADY_SIGNALED for a sync with no backend handle,
// PipeApplier.cpp:175-177 answers GL_ALREADY_SIGNALED for a fence with no native
// object (a fence that provably did no work), and ClientWaitSync() in DirectGLES.cpp does the same
// for an absent sync. A lost device is that same class of fact.
//
// WHAT ABORTING HERE COSTS, MEASURED: after a clean device loss Minecraft 26.2's next
// glClientWaitSync - the chunk-upload fences - reached this line with DECLINED and died
// of Fatal{ProtocolCorruption, "Fence.reply"}, rc=134, instead of the no-op the design
// promises ("GL calls become no-ops", design/08-runtime-and-platform.md:33).
// A declined reply is an answer, not a corruption: the server declines these rows when
// it cannot answer them, and the client reports a verb that did not happen (the
// device-lost latch, a doorbell that died during teardown) as declined. It gets the
// frontend's answer for a fence it cannot wait on, signaled, so GL calls after a device
// loss are no-ops (ARCHITECTURE.md §15.3) rather than aborts.
if (status == Wire::ReplySink::kStatusDeclined) {
MGLOG_E_ONCE("MG_Remote client: the peer DECLINED a %s reply - the verb did not "
"happen (a lost device, or a teardown); answering the no-op the "
@@ -503,31 +456,14 @@ namespace MobileGL::MG_Remote::Client {
#include "QueryEmit.inc"
// CLASS C - each remaining slot names its first blocker.
// Class C - each remaining slot names its first blocker.
// =============================================================================
//
// PARTITIONED BY THE P5b PACKAGE THAT OWNS THE FLIP (docs/archive/contracts/CONTRACT-P5B.md,
// docs/archive/notes/p5b/BRIEF-P5B.md), so that four packages migrating in parallel edit four
// DISJOINT lists and four DISJOINT counts rather than one list and one number. To flip a
// slot a package (1) removes its X row from ITS list, (2) assigns the real emitter in
// BuildRemoteEmitTable's class-B block, (3) raises ITS kEmittedSlots* by one. The
// per-package ownership assertions below then still hold, the totals stay arithmetic,
// and a slot that changes class without changing the arithmetic is a build break. The
// X-macro shape is kept so the DEFINITION and the ASSIGNMENT cannot drift apart. Three
// slots carried a body the macro cannot (DispatchCompute, DispatchComputeIndirect,
// SetSwapInterval) and were written out by hand below; all three are class B now.
//
// d1 indexed / instanced / multi-draw / indirect draws -> draw_vbo (59), its
// kDrawIsIndirect tail and its kDrawHasUserIndices span
// i1 image bind, compute, barriers, copy-image, storage block -> bind_shader_image
// (72), launch_grid (60), memory_barrier (61), resource_copy_region (53),
// set_storage_block_binding (75)
// t2 the XFB spans and object bind, the patch parameter -> begin/end/pause/resume_
// stream_output (62..65), bind_stream_output (74), patch_parameter (73)
// f1 the clear family, the framebuffer-sourced copies, mips -> clear (57),
// copy_framebuffer_to_texture (76), generate_mipmap (54)
// tail the wave-3 remainder nothing measured: queries, syncs, the texture readbacks,
// the DSA blit, the swap interval (census-classC.md "static cross")
// One X-macro list per slot group (D1 draws, I1 image/compute, T2 XFB and patch
// parameter, F1 clear/copy/mipmap, TAIL queries/syncs/readbacks), each paired with its
// kEmittedSlots* count so the ownership static_asserts below hold. A slot moves to class B
// by removing its row, assigning its emitter in BuildRemoteEmitTable and raising its
// group's count. The X-macro keeps a stub's definition and assignment in step.
// d1 v1 flipped all nineteen (DrawElements, DrawElementsBaseVertex, MultiDrawArrays,
// MultiDrawElements, MultiDrawElementsBaseVertex, MultiDrawElementsIndirect,
+8 -50
View File
@@ -6,56 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The client's emitting function table. Owner: package c1. Signatures by c0.
//
// MG_Backend/Init.cpp:44 assigns gBackendFunctionsTable from the active backend object, and
// 91 MG_Impl/GLImpl sites call through it directly. So a BackendObject_Remote has to return a
// COMPLETE table, and "complete" is a bigger number than R-4's headline:
//
// GLFunctionsTable (BackendObject.h:117-292) = 69 function pointers
// + Bool PrefersCpuXfbPrimitiveAccounting
// GlobalBackendFunctionsTable (BackendObject.h:293-299) = the above, + Present, + SetSwapInterval
// = 71 function pointers in total
//
// R-4's rule, restated over all 71: NO SLOT MAY BE NULL, and no slot may fall through to a
// driver. A null slot is 91 potential null calls; a pass-through slot is a split lane quietly
// running monolith and going green, which is the one outcome every gate in this phase exists
// to prevent. A verb P5 does not implement gets a slot that raises
// Fatal{UnmigratedVerb, "<slot>"} - the same shape as MGPipeInputPoisonFatal, live at every
// log level, MGLOG_F + std::abort.
//
// WHICH SLOTS GET A REAL EMITTER IS DECIDED BY THE VERB CENSUS (R-4), not guessed here:
// docs/archive/notes/p5/verb-census.md. CONTRACT-P5.md §7 carries the resulting THREE-CLASS SPLIT and
// it is not to be re-derived:
//
// A. ANSWERED LOCALLY from the caps mirror, never emitted and never Fatal (R-15) - two
// slots, GetIntegeri_v and IsTimerQuerySupported, plus the Bool member
// PrefersCpuXfbPrimitiveAccounting, which is not a slot. GetIntegeri_v is the one that
// would otherwise sink the phase: it is reached by the FIRST glCompileShader of every
// context (CompileEnv.cpp:134-138 <- Core.cpp:39), not by any verb, so a Fatal there
// aborts every scenario before it draws anything.
// B. EMITTED in P5 - five slots: Clear, DrawArrays, ReadPixels, BlitFramebuffer, Present.
// Present has ZERO MG_Impl call sites: it is reached through EGLImpl.cpp:178 ->
// BackendObject.cpp:396, so mirroring GLImpl will not find it.
// C. Fatal{UnmigratedVerb} - the remaining 64, SetSwapInterval and GetGpuTimestampNs among
// them. (That was P5's partition. P5b..P10 moved every one of them to B; the last,
// SetSwapInterval, is a forward to the server's control channel since P10, and class C is
// empty. UnmigratedVerbFatal below remains the named refusal of a class-B emitter's
// unrepresentable SHAPE - "<slot>+<QUALIFIER>".)
//
// AND THE RULE R-4 WOULD OTHERWISE BREAK. 41 of the 69 slots are null-checked at their call
// site, and several of those checks are CAPABILITY PROBES, not safety checks - BeginOcclusionQuery
// (GL_Query.cpp:481, :785), BeginXfbPrimitivesQuery (:534), SubDataResident. With no null slot
// in this table every one of them answers "supported" and the fallback behind it silently
// disappears. A null check on a slot may not survive into the client: it becomes a caps-mirror
// read, which is what ARCHITECTURE.md §3.3 means by "CallMask replaces 'is this table slot null'".
//
// 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.
// The client's emit table for the remote role: GlobalBackendFunctionsTable (the 69
// GLFunctionsTable slots plus Present and SetSwapInterval), installed by MG_Backend::Init as
// gBackendFunctionsTable. No slot may be null or fall through to a local driver: each emits a
// verb record, answers from the caps mirror (GetIntegeri_v, reached by every context's first
// glCompileShader, and IsTimerQuerySupported), or forwards to the server (SetSwapInterval).
// UnmigratedVerbFatal names a call shape an emitter cannot represent. Sites that probed a slot
// for null read a cap bit instead (SlotCaps.h). Resource, CSO, texture, sampler and program
// records bypass this table and go through the routed tables (MG_Pipe/PipeRoute.h).
#pragma once
#include <Includes.h>
+5 -17
View File
@@ -35,23 +35,11 @@
Int32 status = Wire::ReplySink::kStatusError;
Uint64 returned = 0;
session.EmitAndWait(op, request, size, nullptr, 0, &result, sizeof(result), &status, &returned);
// A DECLINED QUERY IS AN ANSWER TOO (P12), for ReadFenceReply's reason and on the
// same three-value space (PipeWireCodec.h:457-460). The server declines these three
// rows by design whenever it cannot answer them - QueryAvailable :1841-1847,
// QueryResult :1848-1854 and QueryTimestamp :1855-1859 all post kStatusDeclined with
// a zero-length payload - and the client produces the same status once the session
// stops carrying records (ClientSession.cpp:1837 is the device-lost latch, :2025 a
// doorbell that died during teardown). `returned != sizeof(result)` is guaranteed for
// such an answer, so the guard below used to abort on every one of them.
//
// THE ZEROED RESULT IS THE NO-OP EACH ROW ALREADY HAS, and it is what a query has
// when there is no answer YET rather than no answer ever:
// - QueryAvailable -> 0 -> false, "the result is not available" (the same answer an
// unfinished query gives - GL_QUERY_RESULT_AVAILABLE exists to be told that);
// - QueryResult -> Produced = 0 -> the caller returns false with *value untouched
// (:70 spells the healthy analogue of the same answer);
// - QueryTimestamp -> 0, no timestamp to report.
// ERROR stays fatal below, and so does an OK reply that arrived short.
// A declined reply is an answer, as in ReadFenceReply: the server declines these rows
// when it cannot answer, and the client reports a verb that did not happen (device
// lost, teardown) as declined. The zeroed result is each row's "no answer yet" value:
// QueryAvailable false, QueryResult Produced = 0 (the caller returns false, *value
// untouched), QueryTimestamp 0. An error or a short OK reply stays fatal.
if (status == Wire::ReplySink::kStatusDeclined) {
MGLOG_E_ONCE("MG_Remote client: the peer DECLINED a %s reply - the verb did not "
"happen (a lost device, or a teardown); answering the row's "
+8 -16
View File
@@ -6,22 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// IS THE SERVER THIS PROCESS WOULD DIAL THERE AT ALL.
//
// Installed as the system's EGL/GLX vendor, this library is asked first by every GL process on the
// machine, and the session bring-up behind its first display waits up to 20 s for a server that
// may simply not be running. The loaders that ask (EGL and GLX dispatchers, the GBM loader) all
// move on to the next implementation when one declines - so the honest, cheap answer when nobody
// is listening is to decline before that wait starts, and this is the question that decides it.
//
// Only a CONNECT dial can be probed: MOBILEGL_TRANSPORT=spawn with MOBILEGL_IPC_CONTROL naming
// `unix:<path>`, `unix:@<abstract>` or `tcp://host:port`. Everything else - monolith, inproc, a
// fork control that launches its own server, an fd: pair somebody already connected - has no
// remote to be absent and answers NotApplicable. Both settings are read the way the configuration
// loader reads them (environment over the system configuration files), because the probe runs
// before MobileGL initializes.
//
// MOBILEGL_IPC_PROBE_TIMEOUT_MS bounds one probe (default 250, 1..60000); 0 turns probing off.
// Whether the server this process would dial is listening. As the system's EGL/GLX vendor this
// library is asked first by every GL process, and session bring-up would wait up to 20 s for an
// absent server; the EGL/GLX dispatchers and the GBM loader move on when a vendor declines, so
// the library declines early instead. Only a connect dial is probed (MOBILEGL_TRANSPORT=spawn
// with MOBILEGL_IPC_CONTROL = unix:<path>, unix:@<name> or tcp://host:port); anything else
// answers NotApplicable. Settings are read as the config loader reads them (environment over
// system files) because the probe runs before MobileGL initializes.
// MOBILEGL_IPC_PROBE_TIMEOUT_MS bounds one probe (default 250, range 1..60000); 0 disables it.
#pragma once
+8 -91
View File
@@ -6,97 +6,14 @@
// 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 §3.3 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:
// FenceSync used this fallback through P5. P5b §9 moved it to class B: the frontend
// now calls the emitter and the server preserves the optional/native-null fallback.
// GetTexImage/GetTextureImage now emit owned texture readback replies. Their
// split path must never use the frontend shadow after GPU writes.
// GL_Getter.cpp x2 GetGpuTimestampNs and GL_Query.cpp's query probes now use their
// timer/occlusion/primitive capability bits. An advertised path emits real query
// records; only an unavailable native capability reports COUNTER_BITS 0.
//
// 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.
//
// P5b t2 CLOSED THE FIRST OF THOSE TWO, AND THE OTHER SIX SITES NEEDED NOTHING (CONTRACT-P5B.md
// §2 t2, §6.5). The six SPAN sites - :1274 Begin, :1371 End, :1420 Pause, :1435 Resume, :1641
// Delete, :1673 Bind - are guards over a slot that is now class B in the client's table, so they
// simply call the emitter; their `if (const auto f = ...)` shape is left exactly as it was,
// because under split the slot is non-null and under monolith nothing moved. THE PROBE AT :1290
// IS THE ONE THAT HAD TO CHANGE, and it is the reason this header said the XFB span family would
// be the first to need a bit: it is not a guard on a call, it is a QUESTION ABOUT THE BACKEND
// asked of a table that under split belongs to the client. It now reads
// MGL_BACKEND_SLOT_CAP(EndTransformFeedback, kCapBackendOwnsXfbCapture), the bit the server sets
// from ITS table in MG_Backend/Init.cpp's InitSplitRoles. Espryt registers the slot and answers
// yes; Magma registers no XFB slot, answers no, and the client keeps reordering for it exactly
// as it does under monolith. GL_Drawing.cpp:844's PatchParameteri stays a plain guard for the
// same reason as the six: the slot is class B now, so "absent" never arises.
//
// DeleteTransformFeedback now emits DeleteStreamOutput with the object's lifetime
// identity; its ordinary slot guard reaches that emitter under a transport.
//
// 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, "<slot>"} 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). It did: P5b's contract granted t2
// exactly that one bit, kCapBackendOwnsXfbCapture (CONTRACT-P5B.md §6.5, §8), and t2 added it.
//
// 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.
// Capability probes over GLFunctionsTable slots. A call site that tests a slot for null to learn
// whether the backend supports something cannot do so under a transport, where the client's
// emit table has no null slot; it reads a capability bit instead (ARCHITECTURE.md §3.3).
// MGL_BACKEND_SLOT_CAP(Slot, CapBit): split reads the server's CapBit; monolith tests the slot.
// MGL_BACKEND_SLOT_LOCAL(Slot): no published bit; split answers "absent" so the site's fallback
// runs. Use it only where that fallback is correct, not silently wrong (the XFB capture-order
// probe reads kCapBackendOwnsXfbCapture for that reason). Monolith tests the slot.
// Without MOBILEGL_BUILD_DISAGGREGATED both expand to the plain null check.
#pragma once
#include <Includes.h>
+52 -243
View File
@@ -6,27 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The ENCODE TWIN of gMGPipeWireRecordApply: thirty-eight 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-eight rows against PipeTables.inc in one pass instead of reading thirty-eight 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-eight. 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.
// The encode twin of gMGPipeWireRecordApply: the emitters that turn a routed MGPipe table call
// into a wire record (install order: WireTables.h). On the server role's own thread an emitter
// runs the server's code directly; otherwise it requires a session, stages its blob (the payload
// is copied because its MGPBlobRef must name the SEG_STAGE run), emits through EmitAndWait and
// posts any answer into MG_Pipe's reply mailbox. The macros keep the regular rows checkable
// against PipeTables.inc at a glance. These are state and object records, not verbs, so they
// never call PushPersistentMapsBeforeVerb / MarkGpuWritesFor*: those are pre-verb hooks
// (MG_Impl/Pipe/Verb/VerbPort.cpp) and must not run before every set_dynamic_state.
#include "WireTables.h"
#include <MG_Remote/FatalFunnel.h>
@@ -68,34 +55,12 @@ 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` one barrier budget 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.
//
// AND IT STAYS OUT OF LINE (P5d round 3, package D). OnApplyThread() became an inline
// relaxed load plus a thread-pointer compare in ServerLoop.h, so the body this forwards to
// is now smaller than the call that reaches it - but inlining THIS would mean including a
// server header from WireTables.h, and the declaration above exists precisely so PipeFill
// does not have to. One PLT hop per verb is the price of that boundary; the four-part
// predicate behind it, which was the measured cost (2.66% self on the client thread at
// head 56a77348), is gone either way. The "one atomic load" the paragraph above promised
// is now literally what it costs.
// True on the server role's own apply thread. Under inproc the server's backend bring-up and
// applier run in this process and reach these emitters; a record published there would wait
// for the very thread meant to apply it (Fatal{BarrierTimeout} during bring-up). So on that
// thread an emitter runs the server's code directly, as the decoder does. Under spawn the
// answer is constant per process. Declared in WireTables.h because PipeFill.cpp and
// EGLImpl.cpp need it too; kept out of line so that header includes no server header.
Bool RunsAsTheServerRole() { return Server::ServerLoop::OnApplyThread(); }
namespace {
@@ -322,102 +287,21 @@ namespace MobileGL::MG_Remote::Client {
// "always accept" is ID-39's 66 lost DirectVulkan uploads and "accept if we emitted" is
// the same bug wearing a counter.
// ---- P12: the CREATE WINDOW, AND WHY IT IS OFF ----------------------------------------
//
// THE IDEA. The create row is the last per-record round trip on the load path (4,536 of one
// frame's 4,543 reply waits) and the one row whose answer is not optional: PublishCreate
// latches "the applier holds this handle" on it, and that latch is what every converted row
// reads. So this row would not STOP reading its answer - it would DEFER it. Up to
// MOBILEGL_IPC_CREATE_WINDOW creates go out fire-and-forget, the caller reads a provisional
// accept (so the latch is taken), and the answers are collected IN ORDER: the oldest blocks
// when the window is full, and one that comes back DECLINED un-latches its object
// (MGPipeNoteHandleUnpublished), which sends every later row for that object back down the
// blocking path.
//
// IT DOES NOT WORK, AND NOT FOR A REASON THAT TUNING REACHES. MEASURED on the device (the
// same frame, the same binary, the same 101,421,396 B staged, axe aikjs4s8cys8ba7l):
//
// MOBILEGL_IPC_CREATE_WINDOW=4 by_op 2 = 4,536 wall 28.64 s
// MOBILEGL_IPC_CREATE_WINDOW=1 by_op 2 = 4,536 wall 28.64 s
// (the handoff's own pre-window baseline was 31.3 s on this device)
//
// by_op 2 counts the creates that took the BLOCKING path, and it did not move, so no create
// deferred. A temporary counter in the row said why: pushed=24, blocked=4,476, took=20,
// readfail=8,951, pending pinned at 4. The drain READ the window's answers back and failed
// every time.
//
// ---- AND THE DIAGNOSIS BELOW WAS HALF WRONG; READ THIS FIRST ----------------------
//
// The arithmetic that follows is real and the no-reply bit it motivated WORKS (measured:
// the server skipped 50,001 answers and wrote 4,521, cutting a frame's replies by 92%).
// IT DID NOT FIX THE WINDOW: the drain still read back nothing (readfail 8,951 -> 8,872,
// -0.9%). The binding constraint is not how fast the bounded buffer is overwritten but
// that the drain is STRICTLY FIFO AND HAS NO WAY OUT OF A FAILED READ: it retries the
// same head for ever. Measured on the device (P12miss): `want=310` on every miss while
// `replySeq` climbed from 1,504 to 102,915 - one answer lost to a bounded buffer wedges
// the window permanently, and with 4,536 deferred answers that is a certainty rather
// than a risk. docs/archive/notes/p12/CREATE-WINDOW-MEASURED.md section 8 has the numbers and the
// shape of the fix (abandon the head: un-latch the object, add it to the suspect list,
// and pop - the DECLINE path reused for "unknown").
//
// WHY, IN THE PROTOCOL'S OWN ARITHMETIC. An answer is readable only until its reply-pool
// slot is reused, and the pool is kDefaultReplySlotCount = 8 slots deep. The frame posts a
// reply for EVERY reply-owning record - not only for the rows that wait, because the server
// cannot know which those are (ClientSession.cpp:1897-1900: "The server posts either way")
// - and that is 55,903 replies in this one frame (op 4 ResourceDestroy is NOT counted: its
// catalogue flags are kNone, so PostReply would Fatal on it and it never answers):
//
// create 4,536 + respecify 20,633 + params 18,056 + sub-data 12,671 + 3,770 + 6 + 1
//
// 8 slots against 55,903 replies is a pool that churns 6,988 times per frame, i.e. 12.3
// replies between two creates: an answer posted at create N is overwritten after EIGHT
// replies, which is 0.65 of a create. There is no window depth for which the deferred
// answer still exists when the client comes back for it - the deferral is not slow, it is
// UNREADABLE. (A 1-deep "window" is the blocking path, which is what the row does.)
//
// AND THE FAILURE IS NOT NEUTRAL: with the window on, the provisional accept latches an
// object the applier may have refused, and the un-latch that would correct it lives in the
// drain - the same drain that cannot read. A refusal therefore becomes an object the client
// believes is published, which is exactly the silent divergence the suspect list exists to
// prevent. So the default is 1: the row is the pre-window shape unless someone sets
// MOBILEGL_IPC_CREATE_WINDOW explicitly, and the machinery below is kept as the record of
// what was tried and what it cost (a fix would have to make a deferred answer outlive
// 12.3 intervening replies, which means a deeper pool, or NOT sending the ~51,000 answers
// nobody will read - and that second one needs its predicate stated exactly, because
// "fire-and-forget" is the WRONG one: the window's own creates are fire-and-forget and
// the drain above reads them, so a bit keyed on wantReply disables the mechanism it is
// meant to enable. The bit has to mean "this answer will never be read" - which is true
// of respecify/params/sub-data and false of a windowed create. See
// docs/archive/notes/p12/CREATE-WINDOW-MEASURED.md section 7, reviewed).
//
// The gates that pin it are in MG_Test/Wire/RemoteClientControls.inc, and they still hold:
// the window is a coherent mechanism, it is simply unusable on a frame with this much
// other reply traffic.
// (THE NARRATIVE ABOVE PREDATES RETENTION and ends at "the default is 1". It is kept as
// the record of what was tried; what made the window work is the declared-answer store,
// and the default is now 10 - docs/Performance/ipc-create-window.md carries the device numbers.)
// ---- the create window (MOBILEGL_IPC_CREATE_WINDOW) ------------------------------------
// resource_create's answer cannot be dropped: an accepted create latches "the applier
// holds this handle" (PublishCreate), and every converted row reads that latch. The window
// defers the answer instead: up to that many creates (at most kCreateWindowMax) go out
// without waiting, the caller reads a provisional accept, and answers are collected in
// order; a declined one un-latches its object (MGPipeNoteHandleUnpublished) and marks it
// suspect, so its later creates block. It runs only on a link that keeps declared answers
// until read (ILink::RetainsReplies). Measurements: docs/Performance/ipc-create-window.md.
constexpr Uint32 kCreateWindowMax = 12;
// THE POOL ARGUMENT THAT USED TO PIN THIS AT 4 IS GONE, AND WHY IT IS GONE IS THE ONE
// THING TO RE-CHECK BEFORE RAISING IT AGAIN. It read: the pool is kDefaultReplySlotCount
// slots addressed seq % count, so an answer is readable only while its slot has not been
// reused, and a window owning half of them would starve the rows that take their answers
// immediately - the change the handoff named as the one worth pinning, "someone raises
// kCreateWindowMax to 8 and steps on the pool". That is true of a link whose answers live
// in a SLOT POOL, and the window does not run on one: CreateWindowEffective() is gated on
// ILink::RetainsReplies, which is false for ShmLink, so on the pool-addressed transport
// the window is 1 and holds nothing. On the link where it does run - StreamLink - an
// answer is kept in the declared-answer store until its reader takes it, so depth costs
// STORE ENTRIES, not pool slots.
//
// SO THE SAME RULE IS RE-DERIVED AGAINST THE RIGHT OBJECT: at most half of StreamLink's
// kRetainedCap = 24, the other half being the one blocking row in flight and whatever a
// retired window has stranded. The store's overflow is a named Fatal and never an
// eviction, so exceeding this costs a run rather than a wrong picture - and the store
// logs its own peak as it crosses, which is what a future raise must be argued from.
//
// MEASURED, device load frame, three interleaved runs per arm: the curve had NOT flattened
// at the old cap (1->2 saved 10.29 s, 2->3 3.74 s, 3->4 1.83 s), which is why this
// constant moved at all.
// kCreateWindowMax is at most half of StreamLink's declared-answer store
// (kRetainedAnswersMax): the other half is for the one blocking row in flight and for
// answers a retired window left behind. The window never runs on the slot-pool link
// (ShmLink retains no replies), so depth costs store entries, not reply slots. Store
// overflow is a named Fatal, never an eviction, and the store logs its peak: argue any
// raise from that peak (docs/Performance/ipc-create-window.md).
static_assert(kCreateWindowMax <= Transport::StreamLink::kRetainedAnswersMax / 2,
"the create window may hold at most half of StreamLink's declared-answer "
"store: the other half belongs to the one blocking row in flight and to "
@@ -609,28 +493,10 @@ namespace MobileGL::MG_Remote::Client {
if (status == 1) ++g_declined;
if (status == 0) ClearCreateSuspect(payload->Resource);
}
// ---- P12: WHY THERE IS NO RESIDENCY BUDGET HERE ANY MORE -------------------------------
//
// There was one, and it was measured: it counted the reply-owning records this client had
// emitted since the oldest window entry was pushed and took that entry back when the count
// approached the reply buffer's depth. On the device it fired on essentially EVERY create
// (budget=4,451 of 4,501 calls) and the drain still read nothing back (taken=63,
// readfail=8,872, by_op 2 = 9,071, wall 27.9 s). The clock was wrong, structurally:
//
// * the window only gets control WHEN A CREATE ARRIVES, and between two creates this
// client emits on the order of a thousand records - at the FIRST failed read the reply
// ring already spanned [1175..1504] while the head was seq 310;
// * what displaces a deferred answer is not what this client emits, it is WHAT THE
// SERVER ANSWERS, and the server is behind: its late answers to the earlier creates
// are what fills a bounded buffer first;
// * so the check fired too late by construction. Tightening it only removed the
// deferral entirely, which is what pushed=65 was.
//
// Retention replaced it, and retention is where the bound now lives: a link that declares
// RetainsReplies keeps an answer until its reader takes it (StreamLink's declared-answer
// store, whose capacity is tied to the window's own depth), and the window does not defer
// at all on a link that does not. That removes the only thing the budget was for.
// ReplyPostings() stays as a measurement.
// The window keeps no client-side residency budget. On a link that reuses reply slots,
// what displaces a deferred answer is the server's reply traffic, not this client's
// emissions, so a budget counted here fires too late. The window defers only where the
// link retains answers, and the store bounds it (docs/Performance/ipc-create-window.md).
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");
@@ -732,27 +598,12 @@ namespace MobileGL::MG_Remote::Client {
++g_emitted;
}
// A PARAMETER CHANGE FOR AN OBJECT WHOSE EXISTENCE IS CONFIRMED OWES NO ANSWER (P12).
//
// The acceptance this row used to wait for says one thing: "the applier holds a record for
// this handle". That is a fact about the SERVER's table, and the client already keeps its
// own copy of exactly that fact - MGPipeHandleIsPublished, set when the object's create was
// ACCEPTED and never cleared while the record lives (the applier keeps texture records
// across a make-current: MGPipeApplierReset clears only MGPipeWorkingState). So the wait
// is asking the server a question the client has already answered.
//
// WHAT REPLACES IT, on the device run's numbers: this row paid 18,056 of one frame's
// 43,232 round trips at ~5 ms each, for records carrying a few hundred bytes. The object's
// ONE confirmation is its create, paid at its first use (the emitter sends it proactively
// when the latch is clear - EmitTextureParams' HealUnpublishedRecord); every parameter
// record after that is emitted fire-and-forget and the caller reads a PROVISIONAL accept,
// exactly as resource_subdata has done since item B.
//
// AND WHEN THE LATCH IS CLEAR THIS ROW STILL WAITS, which is the whole fallback: an object
// whose create was refused stays unpublished, so its next parameter record takes the real
// answer and the emitter's existing one-retry heal runs. A refusal can only reach here
// for an object the client thinks it never published, or for a bug - the two cases that
// were already the only ones the per-record wait could name.
// set_texture_params owes no answer once the texture is published. MGPipeHandleIsPublished
// is the client's copy of "the applier holds this handle": set when the create is accepted
// and kept while the record lives (MGPipeApplierReset keeps texture records). A published
// texture's record goes fire-and-forget with a provisional accept. An unpublished one still
// waits, so a refusal reaches the caller as a refusal. EmitTextureParams pays the
// confirming create at the texture's first use (HealUnpublishedRecord).
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");
@@ -878,22 +729,12 @@ namespace MobileGL::MG_Remote::Client {
} else {
MG_Pipe::MGPipeClearRespecifiedLevel(record);
}
// P12: WHETHER THIS RECORD OWES AN ANSWER IS ONE PREDICATE, and it lives beside the
// route (PipeRoute.h, MGPipeResourceRespecifyWantsItsReply) so the emitter and the wire
// cannot disagree about it. The two halves are argued there; the short version is that the
// buffer half's Bool is discarded at both call sites, and the texture half's is owed
// until the object's create has been accepted - the confirmation it pays at first use.
//
// WHAT A PROVISIONAL ACCEPT DOES TO THE TEXTURE HALF, stated here because it is a
// deliberate trade rather than an oversight: the emitter's acceptance mirror
// (entry.LastDesc) now advances on an answer nobody waited for. ID-18 M3
// (TextureEmit.h:885-888) forbids exactly that for a record whose verdict is UNKNOWN -
// "the next identical call is suppressed against a record that was never stored" - and
// what makes it safe here is that the object is PUBLISHED: the client is not guessing,
// it is reading a latch the server set by accepting this handle's create. The residue
// is the one case where the server loses a record it had, which today has no production
// path (PipeApply.cpp:1593's only callers are tests), and whose guard is the
// invalidation notice the route's notes call ①.
// Whether this record waits is MGPipeResourceRespecifyWantsItsReply, shared with the
// route (ARCHITECTURE.md §13.1.1). On the texture half a provisional accept advances
// the emitter's acceptance mirror (entry.LastDesc, TextureEmit.h) without a verdict.
// That is safe only because the texture is published: the server accepted its create
// and keeps its record. The one exception, the server dropping a live record
// (MGPipeApplierReleaseObjectRecords), has no production caller.
const Bool wantsReply = MG_Pipe::MGPipeResourceRespecifyWantsItsReply(record);
Int32 status = Wire::ReplySink::kStatusError;
if (!wantsReply) {
@@ -1284,46 +1125,14 @@ namespace MobileGL::MG_Remote::Client {
void InstallClientWireTables() {
using namespace MG_Pipe;
// DirectGLES normally installs this notice while creating its handle
// backend. An independent client never creates that backend: without a
// client emitter, NotifyAndFree silently drops object_death before
// freeing the slot. Keep inproc's existing backend dispatcher, and give
// the remote client the same wire delivery without a local twin table.
//
// P7 PACKAGE L: THE BACKEND TEST IS GONE, AND ITS ABSENCE IS THE POINT. P6.5 wrote
// `ActiveBackendType == DirectGLES` because DirectGLES was the only backend with a
// two-process lane, so the condition read as "the case this can happen in" rather than
// as a policy. Under a Magma spawn or tcp client it is a silent hole of exactly the
// shape P6.5 closed for Espryt: CtWireScenario's two death cases would report
// ObjectDeaths=0 and PASS NOTHING, because no notice was ever installed and
// NotifyAndFree would drop every object_death before freeing the slot - a green that
// means "the mechanism is absent". MEASURED on this tree: with the install site
// disabled, both DirectGLES spawn death cases red on CtWireScenario.cpp:187/:246 with
// `Expected: (afterCounters.deaths) > (deathsBefore), actual: 0 vs 0`.
//
// THE REMAINING TWO CONDITIONS STILL CARRY P6.5's INTENT, both halves of it:
// * Transport == Spawn: this is the REMOTE client, the one with no local twin table.
// Under inproc the server role is a thread in this process and its backend's own
// dispatcher is the right one.
// * GetStateObjectDeathOps() == nullptr: whoever installed first keeps the notice.
// That is what "keep inproc's existing backend dispatcher" meant, and it is also
// what keeps this from stomping a backend that installs ops of its own later.
//
// MAGMA NOW HAS A DEATH TABLE OF ITS OWN (P7 wave 2 package C, CONTRACT-P7 §5.5:
// DirectVulkan.cpp's g_magmaStateObjectDeathOps), and the two installs do NOT race,
// which is worth stating because the global is a bare last-writer-wins pointer with
// no stacking. They cannot meet: Magma's is installed from
// BackendObject_DirectVulkan::Initialize(), i.e. only in a process that OWNS a
// DirectVulkan backend, and the arm here runs only under `Transport == Spawn`, which
// is by construction the process that owns NO backend at all (InitSplitRoles builds a
// BackendObject_Remote there). So the spawn client keeps this emitter, the inproc and
// server-side roles keep Magma's, and `GetStateObjectDeathOps() == nullptr` below
// keeps meaning what P6.5 meant by it.
//
// MEASURED, both directions (package C's red-once): with Magma's install
// short-circuited, the two CtWireScenario death cases red on the INPROC arm alone
// (`deaths` 0 vs 0) and stay green on spawn and tcp - which is exactly the split of
// responsibility this comment claims.
// A remote client has no backend, so nothing else installs a state-object death notice
// and NotifyAndFree would free slots without emitting object_death. Install the wire
// emitter only under Transport == Spawn (inproc keeps its backend's dispatcher) and only
// when no ops are installed (the first installer keeps the notice). Magma's own ops
// (DirectVulkan.cpp) cannot collide with these through the last-writer-wins global: Magma
// installs only in a process that owns a DirectVulkan backend, and a spawn client owns
// none (InitSplitRoles builds a BackendObject_Remote). CtWireScenario's two death cases
// cover both installs.
if (MG_Config::Transport == MG_Config::TransportMode::Spawn &&
MG_State::GLState::GetStateObjectDeathOps() == nullptr) {
MG_State::GLState::SetStateObjectDeathOps(&kClientStateObjectDeathOps);
+15 -41
View File
@@ -6,31 +6,14 @@
// 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-eight 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 a named refusal rather than a dangling session.
// The client arm of MGPipe routing, the encode twin of gMGPipeWireRecordApply: thin emitters over
// ClientSession::EmitAndWait, installed into the generated tables and the escape table that
// MG_Pipe/PipeRoute.h declares, so every resource, CSO, texture and program record leaves the GL
// thread as a wire record instead of running against a context the apply thread owns.
// InstallClientWireTables() runs last in ClientSession::Start, on the GL thread: after the
// handshake (the peer has mapped the rings), after the first caps snapshot (consumer gates read
// it) and after the apply thread starts (EmitAndWait blocks on appliedSeq). Uninstall runs first
// in Stop, so a straggling routed call is refused by name instead of reaching freed rings.
#pragma once
#include <Includes.h>
@@ -130,23 +113,14 @@ namespace MobileGL::MG_Remote::Client {
// ForgetEndedSession).
void ForgetWireTablesOfEndedSession();
// ---- P14 S1: the EGL context lifecycle's control-plane half -----------------------------
// ---- the EGL context lifecycle's control-plane half -------------------------------------
//
// NOT ring records and NOT routed rows: eglCreateContext/eglDestroyContext are the EGL
// lifecycle, which the catalogue deliberately keeps off the record plane (PipeCalls.def's
// "the eight EGL lifecycle entry points stay virtual functions on pActiveBackendObject"),
// so they cross as two SurfaceOp kinds through the same blocking frame channel the nine
// surface forwarders use. Called by MG_Impl's EGLImpl on the app thread, in the same order
// as the EGL calls themselves - which is the ordering the server's context table needs.
//
// THREE ANSWERS, NOT TWO, for the reason ObjectDeathEmit below has three: "the server said
// no" and "there is no wire for the question to cross" are different facts and the caller's
// answer to each is different. NoWire is the bring-up window before ClientSession::Start and
// the server role's own thread - in both, the pre-S1 shape (the client keeps its own EGL
// books and no server table exists) is the correct one and the context must be KEPT; Refused
// is a server that answered, and eglCreateContext turns it into EGL_NO_CONTEXT, because a
// context whose token the server did not table is one the first bind_context after it would
// be refused for.
// eglCreateContext / eglDestroyContext are not ring records: they cross as SurfaceOp frames
// on the blocking control channel the surface forwarders use, sent by EGLImpl on the app
// thread in EGL call order, which is the order the server's context table needs. NoWire
// (before ClientSession::Start, or on the server role's own thread) means keep the context on
// the client's books alone. Refused is a server answer: eglCreateContext then returns
// EGL_NO_CONTEXT, because the server would refuse that context's first bind_context.
enum class ContextFrameEmit : Uint8 { Sent, NoWire, Refused };
ContextFrameEmit SendCreateContextFrame(Uint64 clientContextToken, Uint64 shareGroupToken, Uint32 flags);
// A destroy has the same three answers; the client's EGL book is dropped either way (that is
+22 -63
View File
@@ -6,24 +6,12 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P6 `dl` (CONTRACT-P6 5.2). THE ONE PLACE A SESSION DIES.
//
// Every `Fatal{...}` under MG_Remote/ used to be an MGLOG_F followed by its own std::abort().
// a6 counted ~92 of them. Scattered, they gave the funnel's three benefits to nobody: there was
// no single place to publish a SessionFault to the peer before dying, no single telemetry point,
// and no way for a census to be sure a new death was named. This is that place.
//
// THE MESSAGE STRING IS PASSED VERBATIM, family word and all. The contract requires the log
// lines, the CI greps and every red-once to be byte-identical, so SessionFail does not compose
// the line from the family - it takes the SAME string the site used to hand MGLOG_F and forwards
// it unchanged. The family enum rides ALONGSIDE, redundantly, so the funnel knows the coarse wire
// FatalCode without parsing the string; a test asserts the enum's name appears in the string, so
// the redundancy cannot rot into disagreement.
//
// NOT in WireLog.h: that header is deliberately dependency-light (ITransport.h's rule, the
// include-graph purity gate), and this needs FatalFamily.h, which pulls the protocol header. The
// header-layer sites that must stay pure keep using WireLogFatal, whose own abort routes through
// the same publisher in WireLog.cpp.
// The one place a session dies. SessionFail logs the line, publishes a SessionFault to the peer,
// counts it and aborts, so every Fatal under MG_Remote/ is named and counted. The message is
// forwarded verbatim, family word included, because log greps and the fatal census match it; the
// MGFatalFamily argument gives the wire FatalCode (ARCHITECTURE.md §15.3.1) without parsing the
// string, and a test asserts the family's name appears in it. Header-only Transport code, which
// must not include FatalFamily.h, uses WireLogFatal, whose abort reaches the same publisher.
#pragma once
@@ -48,35 +36,14 @@ namespace MobileGL::MG_Remote {
// latched fault (SessionLatch below) counts here too: it ends the session just as surely.
::std::uint64_t SessionFaultCount();
// ---- PH-1 (3), ID-P7-1: THE PER-SESSION LATCH ---------------------------------------
//
// WHAT IT IS. At a site whose input bytes the PEER wrote, and which already sits in a
// function that can say "no" (Bool / MobileGLResult), a named fault no longer has to kill the
// process: SessionLatch logs the SAME line SessionFail would (family word and all, so every
// `Fatal{` grep, the census and the SessionFault frame read exactly as before), publishes the
// SessionFault to the peer, records the FIRST fault (family + line), and RETURNS false. The
// site returns failure, the decoder declines, ServerLoop::DrainRing stops applying at its
// next check, the apply thread leaves its loop, and ServerMain::RunSession closes the session
// cleanly and exits kSessionLatchedExitCode - a named, counted end the supervisor reaps and
// then serves the next connection, exactly as P6.5's fork-per-session already does for a
// crash (ID-P7-1: "下一连接照常服务" is the supervisor's, not the latch's).
//
// WHERE IT IS ARMED, AND WHY ONLY THERE. ArmSessionLatch() is called by exactly one site:
// ServerMain::RunSession, i.e. a served session - the spawn / TCP session child (with or
// without --serve), whose process IS the session, and (P14 S2) each session the in-process
// display server runs, which arms ITS OWN latch domain so a fault there ends that session and
// no other. Unarmed, SessionLatch IS SessionFail - same line, same abort - and
// that is the answer everywhere else, deliberately:
// * INPROC keeps Fatal. CONTRACT-P5's R-2 arms (rules A/B/C, the four honesty arms) are
// Fatal{ProtocolCorruption} in the formal wording, and under inproc the client and the
// server share one process: a latched-but-alive server would leave the client's GL thread
// parked on a barrier in the SAME process with no EOF to wake it, and there is no
// supervisor to serve a "next connection" - the process is the session. So the latch
// changes nothing inproc and every death test stays a death test.
// * THE CLIENT never arms it: a client-side arm of a shared helper (the encoder's honesty
// pass) is a local bug, not a peer's bytes.
// The census (scripts/ci/fatal_census.py rule 2) checks SessionLatch calls the way it checks
// SessionFail calls: the string must carry its `Fatal{Word` and the word must have a .def row.
// ---- per-session latch ---------------------------------------------------------------
// At a site whose input bytes the peer wrote and that can already return failure, an armed
// SessionLatch logs SessionFail's line, publishes the SessionFault, records the first fault
// and returns false; DrainRing stops applying and RunSession ends the session with
// kSessionLatchedExitCode. Only ServerMain::RunSession arms it, in the session's latch domain.
// Unarmed it is SessionFail: inproc keeps every Fatal, since a latched server would leave the
// client's GL thread parked in the same process, and the client never arms it.
// scripts/ci/fatal_census.py checks SessionLatch strings like SessionFail's.
void ArmSessionLatch();
bool SessionLatchArmed();
// P12 (D5): THE IN-PROCESS DISPLAY SERVER RUNS SESSIONS ONE AFTER ANOTHER IN ONE PROCESS, so
@@ -101,22 +68,14 @@ namespace MobileGL::MG_Remote {
// Every fault this armed process latched or declined after the first (>= 1 once latched).
::std::uint64_t SessionLatchCount();
// ---- P14 S2: ONE LATCH DOMAIN PER SERVED SESSION ------------------------------------
//
// THE LATCH IS PER SESSION NOW, and that is a correctness requirement of serving several in
// one process, not a tidy-up. Its three readers sit on a session's own threads - the apply
// loop's park, DrainRing's per-pop check, ServerMain's control loop - so a latch raised by
// session A used to stop session B's apply thread mid-ring and end B's control loop: one
// peer's bad bytes took down its neighbours. ResetSessionLatch had the mirror problem: the
// reap of a finished session cleared the latch a live one was standing on.
//
// A DOMAIN is the latch state, and a THREAD IS BOUND TO ONE. SessionRuntime opens a domain per
// served session and ThreadSessionScope binds both of that session's threads to it. A thread
// that has bound nothing uses DOMAIN 0, the process default - which is exactly the old
// behaviour, and is what the client, the inproc one-process shape and every unit case get.
//
// 0 IS RESERVED FOR THE PROCESS DEFAULT and FatalLatchDomainOpen never returns it (0 on
// exhaustion, which degrades to "shares the default" rather than to a crash).
// ---- one latch domain per served session ---------------------------------------------
// The latch is per session so one peer's bad bytes cannot stop a neighbour in the same
// process: its readers (the apply loop, DrainRing, ServerMain's control loop) run on the
// session's own threads. SessionRuntime opens a domain per served session and
// ThreadSessionScope binds both of its threads to it; an unbound thread uses domain 0, the
// process default (client, inproc, unit tests). FatalLatchDomainOpen returns 0 only when
// domains run out, and the session then shares the default.
// See docs/Disaggregated/design/11-state-ownership.md.
inline constexpr int kMaxLatchDomains = 64;
int FatalLatchDomainOpen();
// The domain's state goes; no thread may still be bound to it (the session's apply thread is
+7 -16
View File
@@ -49,23 +49,14 @@ namespace MobileGL::MG_Remote {
return true;
}
// PH-7 (1)(2), ID-P7-3. THE TOKEN POLICY, ONCE, FOR BOTH SERVER-SIDE SITES.
//
// ServerSession::Accept and ServerMain::RunSession each had their own reading of it (see
// Transport/AuthToken.h for what the three readings were). They now call this, so the answer
// to "is this peer allowed to open a session" is one function with one comparison, and it is
// the constant-time one.
//
// THE FOUR STATES, stated here because they were implicit and therefore different at each
// site:
// in-process - there is no peer; the "connection" is this process. Never authenticated.
// no token - not an empty credential: the LISTEN policy has already confined this
// server to loopback (SocketTransport.cpp's ListenTcp), so there is nothing
// left for a token to add. A peer that sends one anyway is not refused for
// it; ServerMain used to do that and no document ever said so.
// The one token check every server-side site uses (ServerSession::Accept, ServerMain's
// session and pre-auth paths, PairAcceptor), with a constant-time comparison:
// in-process - no peer; never authenticated.
// no token set - the listen policy already confines the server to loopback
// (SocketTransport.cpp's ListenTcp); a token the peer sends is not refused.
// token, match - welcomed.
// token, mismatch - `Refuse{Authentication}`, detail "token mismatch", and the value is
// not in the line. A missing token field is a mismatch, not a separate case.
// token, mismatch - Refuse{Authentication}, detail "token mismatch", the value never logged.
// A missing token field is a mismatch.
inline MobileGLResult AuthenticatePeerToken(Transport::ITransport& transport,
const ::flatbuffers::String* presented) {
if (transport.Role() == Transport::TransportRole::InProcess) return MOBILEGL_OK;
+8 -32
View File
@@ -6,38 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P5f, package fc: the codec between the inproc SurfaceControlFrame and the wire's
// ::MobileGL::Wire::SurfaceOp / ::MobileGL::Wire::SurfaceReply (protocol.fbs).
//
// The frame is the inproc payload (Server/SurfaceControlFrame.h); this is the ONLY place the
// frame meets the schema. Nothing in the inproc hot path encodes - the frame crosses the one-slot
// channel by value - but the codec is what makes "the same struct encodes under spawn" a tested
// claim rather than a plan, and it owns the two schema seams that are NOT integer translations:
//
// * SurfaceControlOp <-> ::MobileGL::Wire::SurfaceOpKind: numerically aligned for the ten framed ops and
// PINNED by static_asserts in the .cpp, but mapped through an explicit table. The four
// inproc-only kinds have no wire kind and refuse to encode by name.
// * MG_Backend::WindowBackend <-> ::MobileGL::Wire::WindowKind: the two enums do not agree (WindowKind's
// Surfaceless/Pbuffer are surface shapes, not window backends; WindowBackend's MetalLayer had
// no wire enumerator until fc added it), so the mapping is a table with named failures, never
// a cast (f0-egl census, schema gap 3).
//
// ERROR SEMANTICS. Decode is pure: it answers a SurfaceWireError. The FATALS live at the server
// entry point, ServerApplyWireSurfaceOp, because "a wire op the server cannot honour" is a
// contract violation, not a data problem: AndroidNativeWindowArrived dies
// Fatal{UnmigratedSurface, "AndroidNativeWindow:use-MOBILEGL_IPC_SURFACE=server"} (the ANativeWindow* means nothing in the
// server's process, permanently - a windowed client uses MOBILEGL_IPC_SURFACE=server) and everything else dies
// Fatal{ProtocolCorruption, "SurfaceOp"}. P6's control pump is the intended caller; P5f's unit
// tests drive it directly. MetalLayerArrived likewise dies
// Fatal{UnmigratedSurface, "MetalLayer:use-MOBILEGL_IPC_SURFACE=server"}: CAMetalLayer* is also process-local.
//
// P12 (on-screen server window). WindowKind::ServerOwned is the one window kind whose window is
// NOT the client's: the server substitutes its own (ServerLoop.cpp's ServerOwned arm). The codec
// maps it onto the frame-local Server::kServerOwnedWindowBackend and writes/reads nativeToken 0,
// always - so the client's window value never reaches the wire. A ServerOwned op with a non-zero
// token is a peer's corrupt bytes (Fatal{ProtocolCorruption, "SurfaceOp.nativeToken"}, latched);
// SetWindowHandle naming it is a named refusal (SurfaceRefusal::ServerOwnedOnSetWindowHandle),
// answered and not latched.
// The codec between the in-process SurfaceControlFrame and the wire's SurfaceOp / SurfaceReply
// (protocol.fbs); the only place the frame meets the schema. Both enum seams are explicit tables
// with named failures, never casts: SurfaceControlOp <-> SurfaceOpKind (in-process-only kinds
// refuse to encode) and WindowBackend <-> WindowKind (Surfaceless/Pbuffer are surface shapes).
// Decode is pure and returns a SurfaceWireError; the Fatals are in ServerApplyWireSurfaceOp: a
// native window or Metal layer pointer is process-local (Fatal{UnmigratedSurface}; use
// MOBILEGL_IPC_SURFACE=server). WindowKind::ServerOwned always carries nativeToken 0; a non-zero
// token is a latched ProtocolCorruption, and ServerOwned on SetWindowHandle a named refusal.
#pragma once
#include <Includes.h>
+15 -47
View File
@@ -6,32 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// Shared vocabulary of the MG_Remote wire contracts (transport, framing, ring,
// shm). Inherited from the earlier `Feat/CS-Delta-IPC` branch
// (MobileGL/Protocol/mg_protocol_base.h) and cut down to what plan B's
// transport actually needs: result codes, byte spans, a shm region reference
// and the id typedefs.
//
// Deliberately NOT inherited: MobileGLObjectKind / MobileGLObjectScope /
// MobileGLObjectHandle. Plan B does not put GL object identity on the wire at
// all - the frontend allocates {slot, generation} handles in MG_Pipe
// (PLAN-B.md section 4.2.1) and those are the only identity the backend ever
// sees, so a second object-identity vocabulary here would be a drift surface
// with no reader.
//
// This header must stay:
// - pure C (compilable from C and C++, no MG C++ types, no exceptions/RTTI),
// - dependency-free (only <stdbool.h>/<stddef.h>/<stdint.h>),
// - append-only within an ABI major (see versioning rules below).
//
// Versioning rules (contract-wide):
// - Every versioned struct starts with uint32_t structSize.
// - Appending fields at the tail is a MINOR bump; receivers must ignore
// bytes beyond the structSize they know.
// - Changing/removing/reordering existing fields is a MAJOR bump.
// - A major mismatch is a hard, structured failure, never an exception.
// (Plan B keeps the structSize-first discipline as the answer to risk B-R10,
// PLAN-B.md section 14.2.)
// Shared vocabulary of the MG_Remote wire contracts (transport, framing, ring, shm): result
// codes, byte spans, a shm region reference and id typedefs. No GL object identity lives here;
// the wire's only object identity is MG_Pipe's {slot, gen} handle (ARCHITECTURE.md §2.1).
// Keep this header pure C, dependent only on <stdbool.h>/<stddef.h>/<stdint.h>, and
// append-only within an ABI major. Every versioned struct starts with uint32_t structSize.
// Appending fields is a minor bump and receivers ignore bytes past the structSize they know;
// changing, removing or reordering fields is a major bump, and a major mismatch is a hard,
// structured failure.
#ifndef MOBILEGL_REMOTE_PROTOCOL_BASE_H
#define MOBILEGL_REMOTE_PROTOCOL_BASE_H
@@ -51,27 +33,13 @@ extern "C" {
#define MOBILEGL_PROTOCOL_ABI_MAJOR 1
#define MOBILEGL_PROTOCOL_ABI_MINOR 0
// The CONTROL schema's revision (protocol.fbs), mixed into wireFingerprint. The fingerprint's
// other inputs are struct sizes and codec digests of the DATA plane; nothing moved it when a
// control message changed shape, so a peer built before a control-schema change agreed with one
// built after it and the difference surfaced as whatever the first misread field did. Bump this
// with every protocol.fbs change a peer must agree on.
//
// THE PIN. This is a hand-bumped integer, and the bump is the only thing that moves the
// fingerprint - so a protocol.fbs edit that forgot it would put old and new peers back in
// silent agreement. scripts/ci/protocol_revision_pin.py holds {revision -> sha256(protocol.fbs)}
// (protocol_revision_pins.json) and refuses a schema whose digest is not the current revision's;
// it runs as ProtocolSchema.RevisionPinsDigest (label unit) and in CI's flatc-check job. After a
// bump: `python3 scripts/ci/protocol_revision_pin.py --write` records the new row.
// 1 P7 wave 2-F, PH-7 (4): Welcome.dataNonce and the DataBind message.
// 2 P7 (p7/spawnhang): the SurfaceProgress message.
// 3 P12 (on-screen server window): WindowKind.ServerOwned, the SurfaceRefusal enum and
// SurfaceReply.width/height/refusal.
// 4 P11 PAIR: the PairBind message - a socket client's control and aux connections pair by
// the nonce each presents first, not by arrival order.
// 5 P11 B2: LinkTerms.adoptTier - the client's persistent-map adoption tier ask (T0 or T2).
// 6 P14 S1 (docs/Disaggregated/design/11-state-ownership.md): SurfaceOpKind.CreateContext /
// DestroyContext, plus SurfaceOp.shareGroupToken and SurfaceOp.contextFlags.
// The control schema's revision (protocol.fbs), mixed into wireFingerprint; the fingerprint's
// other inputs cover only the data plane. Bump it with every protocol.fbs change a peer must
// agree on, or old and new peers handshake and then misread a field.
// scripts/ci/protocol_revision_pin.py pins each revision to sha256(protocol.fbs)
// (protocol_revision_pins.json) and fails a schema whose digest is not the current revision's;
// it runs as the ProtocolSchema.RevisionPinsDigest test and in CI's flatc-check job. After a
// bump, record the new row with `python3 scripts/ci/protocol_revision_pin.py --write`.
#define MOBILEGL_PROTOCOL_CONTROL_REVISION 6
#define MOBILEGL_ABI_VERSION(major, minor) (((uint32_t)(major) << 16) | (uint32_t)(minor))
+8 -16
View File
@@ -6,22 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P11 B2 (docs/archive/contracts/CONTRACT-P11.md B2): the server's end of T0's descriptor channel.
//
// WHO READS THE FD, AND WHY IT CANNOT DEADLOCK THE APPLY THREAD. The APPLY THREAD reads it,
// inline, when it decodes the map_persistent record whose seq the Offer names - nothing else reads
// a session's aux socket after the handshake, so there is no second reader to race. The wait
// cannot deadlock because of the client's order: it queues the Offer (ShareFd returns once the
// kernel - or the in-process queue - holds it) BEFORE it publishes the record, so when the apply
// thread sees the record the descriptor is already there, and the wait depends on nothing the
// client has still to do. A client emits at most one T0 map_persistent at a time (the row blocks
// on its reply), so at most one Offer is ever outstanding and the aux queue (10 datagrams on an
// Android kernel) cannot fill. The wait is still bounded (AdoptT0::kOfferWaitMs): an Offer that
// never comes is a named DECLINED, never a hang.
//
// Offers are matched by seq, not by arrival order. One that arrives for a LATER record is kept
// for it; one for an EARLIER record (its record already timed out and was declined) is closed
// and counted as stale.
// The server's end of the aux-socket descriptor channel: T0 map_persistent Offers and shared-image
// descriptors, matched by record seq and told apart by their sideband's magic. Only the apply
// thread reads it, inline, when it decodes the record the descriptor belongs to. It cannot
// deadlock: the client queues the descriptor before it publishes the record, and a T0
// map_persistent blocks on its reply, so at most one Offer is outstanding (an Android aux queue
// holds 10). The wait is still bounded (AdoptT0::kOfferWaitMs); a missing Offer is a named
// DECLINED. A descriptor for a later record is kept for it; one for an earlier, already declined
// record is closed and counted as stale.
#pragma once
+8 -30
View File
@@ -6,36 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P11 B1 (CONTRACT-P11 B1): WHERE THE APPLY THREAD RUNS AND HOW LONG IT SPINS, CHOSEN PER SESSION
// FROM WHO THE CLIENT IS AND WHICH DATA PLANE IT USES.
//
// `auto` used to mean one thing for every session: the apply thread pinned to every big core
// (DetectBigCoreMask, 0xc0 on the workload device) and spinning MOBILEGL_IPC_SPIN_US before each
// park. That is right for inproc and the same-app fork spawn (A4's measured shapes), and for a client
// on the stream plane, whose records an io thread feeds to the apply thread. It is wrong for a
// client that DIALLED IN from another process over SHARED SEGMENTS - an app_process-launched
// program (the fd: endpoint), a Termux one, a root or same-app one on `unix:`: the apply thread
// spins on the ring that client writes directly, the client's GL thread lands on the same two prime
// cores, and on the Redmi rd12 ran 23-26 fps over shared memory against 45-54 over tcp, 118 when the
// two were placed apart by hand (B0-CROSS-APP.md, HSPIKE.md). So `auto` now reads the peer's Hello:
//
// peer, plane `auto` resolves to
// in-process (No) every big core, the configured spin - unchanged
// forked (Fork) every big core, the configured spin - unchanged
// dialled in (Connect), stream every big core, the configured spin - unchanged
// dialled in, shared segments ONE core, ReservedApplyCore's: the lowest of several prime cores,
// a big core beside a lone prime core, or none - never the only
// prime core - and kConnectedPeerSpinUs unless MOBILEGL_IPC_SPIN_US
//
// The lowest prime core, not the highest, by measurement (CONTRACT-P11 B1): the scheduler puts a
// foreground app's hot GL thread on the HIGHEST prime core (cpu7 in 43 of 44 samples); pinning the
// apply thread there shared that core with it (rd12 6.5 fps), pinning it to the lowest gave 120. A
// client launched through ExternalClientHelper is also kept off the reserved core (the supervisor
// announces it, the broker hands it to the helper), so a client the scheduler would otherwise place
// on it - an adb-shell one - is not starved either.
//
// An explicit mask or `off` wins for every peer, exactly as before: the operator's word is the
// policy. A pure function, so the unit suite can hold every row without a thread or a cpufreq tree.
// Where the apply thread runs and how long it spins before parking, chosen per session from the
// peer (the Hello's DialMode) and the data plane. With MOBILEGL_IPC_SERVER_AFFINITY=auto an
// in-process, forked or stream-plane peer gets every big core and the configured spin; a peer
// that dialled in over shared segments gets at most one core (ReservedApplyCore), never the only
// prime core, and kConnectedPeerSpinUs unless MOBILEGL_IPC_SPIN_US is set, because it writes the
// ring the apply thread spins on and its GL thread wants a prime core. An explicit mask or `off`
// wins for every peer. Pure functions, so the unit suite covers every row; the measurements are
// in ARCHITECTURE.md §14.1.
#pragma once
+8 -29
View File
@@ -6,35 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P12 (on-screen server window), D3/D5/D6/D7. THE JNI GLUE OF THE IN-PROCESS DISPLAY SERVER.
//
// The APK's MobileGLDisplayActivity (android:process=":mglwin") owns a SurfaceView and runs
// the TCP server on a thread of its own process, so a headless client (MOBILEGL_IPC_SURFACE=server)
// renders straight onto that SurfaceView. This file is the whole native half of that Activity:
//
// nativeInstallDisplay ServerDisplayInstance().Install(): ANativeWindow acquire/release plus the
// geometry up-call below. Before the server serves.
// nativeServe mobilegl_server_serve_inprocess(listen) on the CALLING (Java) thread;
// returns its exit code when the server stops or fails.
// nativeStop mobilegl_server_stop_inprocess(): non-blocking, safe on the UI thread.
// nativeAttachWindow surfaceCreated / surfaceChanged: ANativeWindow_fromSurface, Attach (which
// takes its OWN reference), then this call's reference is released.
// nativeDetachWindow surfaceDestroyed: ServerDisplay::Detach, which BLOCKS (bounded) until the
// session rendering into the window has released its backend surface on the
// apply thread. Returns the result's name for the Activity's log.
// nativeUninstallDisplay after the server returned: the display is gone.
//
// THE GEOMETRY UP-CALL. ServerDisplay calls RequestGeometry on the APPLY THREAD, from AcquireFor,
// during a ServerOwned CreateWindowSurface, with the size the client asked for. That thread is a
// native thread the JVM does not know, so it is attached for the call and detached after it (a
// native thread that exits while still attached aborts the runtime). The Java side only POSTS the
// request to the UI thread (SurfaceHolder.setFixedSize / setSizeFromLayout must run there) and
// returns: this must not block and must not call back into ServerDisplay. The class and method are
// resolved here, on the Java thread that installs the display, because a native thread's FindClass
// would search the system class loader, not the app's.
//
// Compiled only for Android with MOBILEGL_BUILD_DISAGGREGATED (CMakeLists.txt, beside
// DriverPostJni.cpp); the guard keeps it inert anywhere else.
// The JNI glue of the in-process display server: the native half of MobileGLDisplayActivity
// (process ":mglwin"), which owns a SurfaceView and runs the TCP server in its own process so a
// headless client (MOBILEGL_IPC_SURFACE=server) renders onto it. nativeInstallDisplay and
// nativeUninstallDisplay bracket nativeServe (blocking, on the calling Java thread); nativeStop
// does not block; nativeDetachWindow blocks (bounded) until the session releases the window.
// RequestGeometry runs on the apply thread, attached to the JVM for the call only, and must not
// block or call back into ServerDisplay (Java only posts to the UI thread). Its class and method
// are resolved at install: a native thread's FindClass would miss the app's classes.
#ifdef __ANDROID__
#include "InProcessServer.h"
+8 -21
View File
@@ -6,27 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P12 (on-screen server window), D5. THE IN-PROCESS DISPLAY SERVER'S ENTRY POINTS.
//
// The on-screen server cannot be the exec'd supervisor that forks a child per session: the window
// it renders into (the display Activity's SurfaceView) is an ANativeWindow in the APP's process, and
// neither exec nor fork carries one. So the Activity's own process serves: a thread it starts calls
// mobilegl_server_serve_inprocess, which is `libMobileGLServer.so tcp://... --serve` with THREADS
// instead of fork - the same pre-auth, the same Busy for an authenticated second Hello while a
// session is live, one session at a time, each session a thread of this process, the latch reset
// between sessions and the backend pinned for the process lifetime. Crash isolation is what it
// gives up: a session's abort (an unarmed SessionFail) takes this process - the Activity - with it.
//
// PRECONDITIONS, the same as mobilegl_server_main's, set in the environment BEFORE libMobileGL is
// loaded (Os.setenv from Java): MOBILEGL_IPC_DIAL=no (else 64), none of MOBILEGL_TRANSPORT /
// MOBILEGL_IPC_SERVER_PATH / MOBILEGL_IPC_RING_MB / MOBILEGL_IPC_STAGE_MB (else 65),
// MOBILEGL_IPC_ROLE=server (forced if missing, as the supervisor does), MOBILEGL_IPC_TOKEN (required
// for a non-loopback listen; >= the minimum length), optionally MOBILEGL_BACKEND_TYPE (the pin),
// MOBILEGL_IPC_LOG_FORWARD, MOBILEGL_LOG_FILE_PATH.
//
// THE DISPLAY is separate: the JNI glue installs ServerDisplayInstance() (ServerDisplay.h) and
// attaches/detaches the window from the SurfaceHolder callbacks. A server with no display installed
// serves offscreen sessions only and refuses a ServerOwned surface by name.
// Entry points of the in-process display server (ARCHITECTURE.md §15.2). Its window lives in the
// app's process and survives neither fork nor exec, so a thread of that process serves, with a
// thread per session instead of a fork: tcp:// runs the --serve supervisor (pre-auth, one session,
// Busy), a unix endpoint up to MOBILEGL_IPC_INPROC_MAX_SESSIONS at once. Latches reset between
// sessions, the backend is pinned for the process, and there is no crash isolation.
// Set before libMobileGL loads: MOBILEGL_IPC_DIAL=no (else 64); none of MOBILEGL_TRANSPORT,
// MOBILEGL_IPC_SERVER_PATH, _RING_MB or _STAGE_MB (else 65); MOBILEGL_IPC_TOKEN for a non-loopback
// listen. The JNI glue installs ServerDisplayInstance(); without one, ServerOwned is refused.
#pragma once
+7 -32
View File
@@ -6,38 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P11 PAIR. A SOCKET CLIENT'S TWO CONNECTIONS ARE PAIRED BY WHO THEY ARE, NOT BY WHEN THEY ARRIVED.
//
// SocketTransport::ConnectTo opens two connections to one listener - control, then aux (the
// SCM_RIGHTS socket; on tcp:// the data connection). The server used to take "the next two accepted
// connections, first is control" (SocketTransport::AcceptPair). That was a protocol only while
// nothing else could reach the listener, and on the device it was not (B0-CROSS-APP.md F2): the
// server app's readiness probe - one connect() and close() - became the next client's control
// connection, that session read the probe's socket and ended ("control peer closed before sending
// a first frame"), and the client got no Welcome. Two clients connecting at the same moment could
// just as well swap halves.
//
// Now each connection's first frame is a PairBind (protocol.fbs, control revision 4): the client's
// nonce, the same on both, and which half it is. This acceptor keeps every accepted connection in a
// small set until it has presented one, and hands out a control and an aux connection only when
// their nonces match. What it does with everything else:
// - a connection that closes having sent nothing (a readiness probe) is closed, quietly;
// - a first frame that is not a PairBind is refused by name. A Hello is a client from before
// PairBind: it is asked what the session would ask, in the session's order - the token, then the
// wire (Handshake.h) - so it hears Refuse{WireFingerprint} as it would from any session, and one
// that passes both is still refused, because it cannot be paired;
// - a connection that has not presented a PairBind, or whose partner has not presented the same
// nonce, within the budget is dropped - a control connection with a refusal, an aux connection
// (whose client reads descriptors there, not frames) by closing it - and nobody else's
// connection is touched;
// - past kPairMaxUnpaired connections the oldest one that has not identified itself (else the
// oldest) is dropped the same way.
//
// It never reads a byte past a PairBind (FirstFrameAssembler): on control the Hello follows it, and
// the session reads that itself; on aux the descriptors follow it.
//
// Stateful because pairs complete out of order: with A-control, B-control, B-aux, A-aux, B is
// handed out first and A-control has to still be here for the call that hands out A.
// Pairs a socket client's two connections (control, then aux: the SCM_RIGHTS socket, or the data
// connection on tcp://) by nonce rather than arrival order, so a readiness probe or two clients
// connecting at once cannot be mispaired. Each connection's first frame is a PairBind with the
// client's nonce and its half; nothing past it is read. A connection that closes having sent
// nothing is closed quietly; a Hello is checked token then wire, as a session would, then refused;
// one unpaired after the budget, or the oldest unidentified one past kPairMaxUnpaired, is dropped
// without touching others. Stateful because pairs complete out of order.
#pragma once
+20 -71
View File
@@ -206,22 +206,14 @@ namespace MobileGL::MG_Remote::Server {
m_unreportedFences.clear();
}
// P10 (CONTRACT-P10.md §1): WHICH OF THE CLIENT'S FENCES HAS THE GPU FINISHED.
//
// The client answers a poll of a fence it knows is signaled without asking - that is the
// whole of package A - and it learns what is signaled only from here. Walked in CREATION
// order and stopped at the first one still pending: one context's GPU work completes in
// the order it was submitted, so a later fence cannot be done while an earlier one is not,
// and the steady-state cost is one status query per drain batch. (Were a backend ever to
// finish them out of order, the later fence would only be reported late - an answer the
// client then gets by its own round trip - never early.)
//
// `flush`: the apply thread is about to go idle. A fence whose commands the driver still
// holds never signals on its own, and an idle server submits nothing more, so the first
// pending fence is asked through ClientWaitSync(GL_SYNC_FLUSH_COMMANDS_BIT, 0) - the one
// backend-neutral way both backends take to submit what they hold - instead of the plain
// status query. Only when idle: mid-stream the next records flush soon enough, and a
// submit per batch would cost Magma its command-buffer batching.
// Reports which of the client's fences the GPU has finished; the client answers a poll of a
// reported fence without a round trip. Walked in creation order and stopped at the first
// pending one: one context's work completes in submission order, so this costs one status
// query per drain batch, and an out-of-order finish would be reported late, never early.
// `flush`: the apply thread is about to go idle, and a fence whose commands the driver still
// holds never signals on its own, so the first pending fence is queried with
// ClientWaitSync(GL_SYNC_FLUSH_COMMANDS_BIT, 0). Only when idle: a submit per batch would
// break Magma's command-buffer batching.
Uint32 ServerVerbSink::ReportSignaledFences(Bool flush) {
if (m_unreportedFences.empty()) return 0;
ServerSession* session = ServerSession::Active();
@@ -719,44 +711,14 @@ namespace MobileGL::MG_Remote::Server {
// dropped before this function returns, and s1's SessionConsumer::ApplyOne publishes
// appliedSeq only after that - so by the time the client is runnable the flag is down.
const Client::ClientSession::ScopedApplierEntry insideApplier;
// P5e (id), CONTRACT-P5E §2.1 / §4.4: STAMP WHETHER THE CLIENT IS PARKED BEHIND THIS
// RECORD, before anything can ask. It is the input to the allocator guard's exemption
// (SlotAllocator.cpp) and to every frontend-keyed twin member that survives as monolith
// glue (SlotTables.h), and it has to be up before DecodeAndApply because the sinks those
// reach are exactly the askers. MGPipeBarriered answers true for every record until ra
// lands the wait rule, so this line changes nothing this phase and is the line ra
// rebases onto rather than adds.
//
// AND THE STAMP IS `true` UNTIL ra LANDS THE CLIENT'S HALF (ID-103). The predicate
// describes what the client WILL do once EmitAndWaitTails follows the wait classes;
// today it still blocks after every record, so a `false` stamp here would withdraw the
// §4.4 exemptions from a probe the client's own wait still makes safe - a refusal with
// no defect behind it. The predicate is computed on every record all the same, so it is
// exercised for the whole phase rather than first run on the day it starts deciding.
//
// AND THE SERVER'S OWN CAPABILITY IS THE SECOND CONJUNCT (P5e gl, ID-111). The constant
// above says what the CLIENT will do once ra's wait rule is live; it says nothing about
// which server this is. kCapRunAheadApply is never published on DirectVulkan (ID-90,
// MGPipeRunAheadCapBitsFor), yet draw_vbo / blit / clear / launch_grid are all
// kWaitNone - so a stamp that read the build constant alone would, on the day it flips,
// have a MAGMA server mark every draw record UNBARRIERED while its client is still
// lockstep. CountBarrierPull (PipeInputs.cpp) is an unconditional Fatal on an
// unbarriered pull, no knob involved, and Magma's residual fill is its ONLY source for
// the seven pointer-backed fields: Magma would die on its first draw and take
// MagmaP7AllocatorDebtScope's exemption with it.
//
// BOTH PREDICATES ARE COMPUTED UNCONDITIONALLY, as arguments rather than as the arms of
// a short-circuit, which is ID-103's reason extended to the capability probe: they are
// exercised on every record for the whole phase rather than first running on the day
// they start deciding.
//
// PH-1 (3): BUT NOT BEFORE THE RECORD HAS BEEN ADMITTED. MGPipeBarriered reads payload
// fields (a DrawVbo's MGPDrawInfo::Flags) and the stamps index per-opcode tables, so a
// record whose ring header is shorter than its own type - or names no opcode - would be
// read past its end here, before DecodeAndApply's pre-gate could refuse it. In an armed
// session child the pre-gate therefore runs FIRST: a refused record latches by name and
// is declined with nothing stamped (ServerLoopTest's short-record case reads the stamp).
// Unarmed it admits everything and the generated gate keeps its death, as before.
// Stamp whether the client is parked behind this record before anything can ask: the
// barrier-pull check (PipeInputs.cpp) and the deferred-destroy queue (PipeFill.cpp) read
// it from inside DecodeAndApply. It is MGPipeBarriered's answer only when this server
// publishes run-ahead; otherwise its client waits after every record, so every record is
// stamped barriered. Both inputs are computed on every record so they stay exercised.
// Admission runs first: MGPipeBarriered reads payload fields and the stamps index
// per-opcode tables, so in a session that latches faults a short or unknown record is
// declined before anything is stamped.
if (!m_decoder.AdmitOrDecline(record)) return false;
const Bool wireSaysBarriered = MG_Pipe::MGPipeBarriered(
static_cast<MG_Pipe::MGPWireOp>(record.kind), record.payload, MG_Pipe::MGPipeApplier());
@@ -779,23 +741,10 @@ namespace MobileGL::MG_Remote::Server {
// before it aborts on the FIRST field inside SyncRenderState.
StampVerbBoundary(static_cast<MG_Pipe::MGPWireOp>(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 runs here, inside ApplyOne, and not after the drain batch:
// SessionConsumer::ApplyOne publishes appliedSeq as soon as this returns, which makes the
// client runnable, so a later clear would write gPipeInputs while the client uses it.
// How a record that is not a verb boundary is read-checked: ARCHITECTURE.md §9.2.1.
LeaveApplier();
return applied;
}
+14 -46
View File
@@ -6,36 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The server's applier bridge. Owner: package v1, with p1 for the stamp rule. Signatures by c0.
//
// IT IS A BRIDGE, NOT AN APPLIER. The applier already exists and is not edited by this phase:
// MG_Pipe/PipeApply.{h,cpp}, 37 MGPipeApply* free functions. This class owns the three things
// that only exist once records arrive over a wire rather than by direct call:
//
// 1. THE VERB STAMP. This is the phase's prerequisite, and it is not in the ROADMAP row.
// MGPipeApplyAccess deliberately does not stamp the poison generations
// (PipeInputs.h:612-618): "a stamp says the filler published this for THIS verb, which is
// the walk's statement, not the applier's". Under split the filler is in another role, so
// NOTHING stamps, every FilledGen[] stays 0, MGPipeInputFieldIsFresh returns false for
// everything, and a pure server aborts on the FIRST read inside SyncRenderState with
// Fatal{UnmigratedPipeInput, "GetRenderStateParameters@<none>"} - before reaching any
// interesting case. So: the server stamps at the verb boundary. p1 defines what is
// stamped and for which verb; v1 places the call. Neither half works alone.
//
// 2. ACCEPTANCE. Four applier entry points return Bool - ResourceCreate, ResourceRespecify,
// ResourceSubData, SetTextureParams - and MapPersistent returns void*. Those returns are
// what the CLIENT gates destructive state changes on (clearing per-level dirty flags,
// latching parameters, adopting a pointer). They go back through the reply slot, id =
// record seq (R-3), and are collected in the barrier's existing wait (R-5). The client
// may not recompute any of them.
//
// 3. R-11, THE BORROWED-POINTER RULE. A SEG_STAGE run is valid from publish until retiredSeq
// passes the record naming it. NO APPLIER ENTRY POINT MAY HOLD A POINTER PAST ITS RETURN.
// The tree has exactly one violation and it is named: GLESBufferResource::hostBytes
// (Managers.h:839), written by Ops_H_SubData (Managers.cpp:1980-1983) and Ops_H_FlushRange
// (:2035), read by six later drains (:2000, :2062, :2080, :2111, :2741, :2843). Under split
// those two must copy into server-owned storage. MOBILEGL_IPC_AUDIT=1's 0xDD fill (R-2.5)
// is the mechanical control that says whether they did.
// The server's applier bridge. The appliers are MG_Pipe/PipeApply's MGPipeApply* functions; this
// class adds what exists only when records arrive over a wire:
// - the verb stamp: the server stamps PipeInputs itself at each verb boundary
// (StampVerbBoundary); the client's stamps cannot stand in for it (ARCHITECTURE.md §9.2.1);
// - acceptance: the Bool appliers and MapPersistent answer through the record's reply slot, and
// the client gates destructive state changes on that answer, never on its own guess;
// - borrowed pointers: a SEG_STAGE run is valid until retiredSeq passes its record, so no applier
// keeps a pointer past its return; MOBILEGL_IPC_AUDIT=1 poisons retired bytes to prove it.
#pragma once
#include <Includes.h>
@@ -63,22 +41,12 @@ namespace MobileGL::MG_Remote::Server {
// action under test is the probe itself).
Bool MGPipeServerPublishesRunAhead();
// ---- the barriered stamp, AS A PURE FUNCTION (ID-111) ---------------------------------
//
// ApplyOne stamps "is the client parked behind this record" before anything can ask
// (CONTRACT-P5E §2.1 / §4.4). Until the P5e integration commit the answer is `true` for
// every record whatever the wire says, because the client still blocks after each one
// (ID-103) - but the day the constant flips, the answer must ALSO ask whether this server
// published the run-ahead bit at all. It is `wireSaysBarriered` only when BOTH halves of
// the run-ahead arm are true; Magma publishes no bit 10, so a Magma server keeps stamping
// `true` and its lockstep client keeps being safe. Without the second conjunct a Magma
// server would stamp every draw_vbo / blit / clear / launch_grid UNBARRIERED (they are all
// kWaitNone) while its client is still parked, and CountBarrierPull aborts unconditionally
// on an unbarriered pull - Magma dies on its first draw, no knob involved.
//
// IT IS A PURE FUNCTION for MGPipeRunAheadCapBitsFor's reason: "Magma never runs ahead" is
// then something a unit case can hold AT THE POST-FLIP VALUE of the constant, rather than a
// claim that first becomes testable on the day the integration commit throws the switch.
// The barriered stamp, as a pure function: ApplyOne stamps whether the client is parked
// behind this record before anything can ask. The wire's answer is used only when the
// client's wait rule is in force and this server publishes kCapRunAheadApply; otherwise every
// record is barriered. A server without that bit has a lockstep client, and stamping its
// kWaitNone draws unbarriered while that client is parked would make CountBarrierPull abort.
// Pure so a unit test can pin "no run-ahead bit, never runs ahead" for any input.
constexpr Bool MGPipeApplierStampsBarriered(Bool clientWaitRuleLanded,
Bool serverPublishesRunAhead,
Bool wireSaysBarriered) {
+7 -43
View File
@@ -6,49 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// PH-7 (5), CONTRACT-P7 §12, docs/archive/notes/p7/ph-f.md §6.4. THE TCP SUPERVISOR AUTHENTICATES BEFORE IT
// FORKS, AND AN UNAUTHENTICATED PEER'S WORK IS BOUNDED.
//
// Two exposures were on the tree before wave 2-F and were recorded by its review:
// (a) the supervisor forked a session child for every accepted TCP connection and let the child
// read the Hello - so any peer that could reach the port cost the server a fork, and a
// silent one held that child for up to 10 s;
// (b) the child ran ValidatePeerHandshake before AuthenticatePeerToken, so an unauthenticated
// peer that sent a Hello with a wrong fingerprint was answered with Refuse{WireFingerprint}
// carrying THIS build's wire fingerprint (and Refuse{BuildFingerprint} its build stamp).
//
// The supervisor now reads every new connection's FIRST FRAME itself, without blocking on any one
// peer, under a short deadline; a Hello is authenticated there, token first, and only an
// authenticated Hello is ever forked for. The pieces that decide are here, free of I/O policy and
// of the supervisor's loop, so they are unit-testable (PreAuthGateTest):
//
// PreAuthKnobs the four knobs, read once from the environment;
// ClassifyFirstFrame what a complete first frame IS - the fuzz arm 1 malformations each land
// on a named shape here, and the identifier is asked before the verifier;
// FirstFrameAssembler reads EXACTLY one frame off a non-blocking socket, a few bytes at a time,
// and never a byte past it: a DataBind is followed on its connection by
// StreamLink's own frames, which belong to the session child;
// AuthBackoff failures per peer address, and the exponentially growing window during
// which that address is refused at accept, before a byte of it is read.
//
// What a failure IS, for the backoff: a Hello whose token did not match, a first frame that is not
// a well-formed control frame, a connection that let the deadline pass, and a connection that HELD
// its pending slot for kPreAuthProbeGraceMs or longer and then closed (or was displaced, below)
// without completing a frame. What it is NOT: a connection that closed within kPreAuthProbeGraceMs
// of its accept without sending a byte (a port probe costs the server an accept and nothing else),
// a DataBind that names no live session (a stale data connection of a session that just ended is a
// legitimate client's), and a refusal the backoff itself issued (counting those would let anybody
// behind a shared address keep that address locked out for ever).
//
// Fix round (the f2-auth critic's reproduction). The first cut counted no silent close at all, and
// the pending slots were one pool for everybody: one address could open MOBILEGL_IPC_PREAUTH_MAX
// silent connections, close them just before the deadline, open them again, and every Hello from
// anywhere was refused Busy for as long as it liked - no failure counted, no backoff ever. Two
// rules close that: a silent connection that held its slot past the grace is a failure (so the
// holder is backed off after MOBILEGL_IPC_AUTH_BACKOFF_AFTER of them), and a full queue is SHARED
// (ChoosePendingToDisplace): a newcomer from an address that holds fewer slots than the busiest
// address displaces the busiest address's oldest pending connection, so no one address can keep
// another out.
// Pre-authentication for the TCP supervisor. It reads each new connection's first frame itself,
// without blocking on any one peer and under a short deadline, checks the token first, and forks
// a session child only for an authenticated Hello. The deciding pieces are free of I/O policy so
// PreAuthGateTest can drive them: PreAuthKnobs (the environment knobs), ClassifyFirstFrame,
// FirstFrameAssembler (reads exactly one frame and never a byte past it: a DataBind's connection
// goes on with StreamLink frames that belong to the session child) and AuthBackoff. What counts
// as a failure and how the pending queue is shared: ARCHITECTURE.md §15.1.1.
#pragma once
+8 -48
View File
@@ -6,54 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P12 (on-screen server window), D3/D6. THE SERVER'S OWN WINDOW, AS ONE PROCESS-WIDE HOLDER.
//
// WHAT IT IS FOR. A client that sets MOBILEGL_IPC_SURFACE=server is headless: it asks the server to
// create its window surface on the SERVER's window (WindowKind::ServerOwned) and learns the
// surface's geometry back from the server. That window belongs to the in-process display server -
// on Android the display Activity's SurfaceView, handed in through JNI (the next stage's glue) -
// and this class is where it is held between the UI thread that owns the window's lifecycle
// (surfaceCreated / surfaceChanged / surfaceDestroyed) and the apply thread that renders into it.
//
// WHO HAS A DISPLAY. Nobody, until Install(): the Linux host server, the exec'd supervisor and
// every session child it forks never call it, so HasDisplay() is false there and a ServerOwned
// request is refused by name (ServerLoop.cpp's ServerOwned arm, SurfaceRefusal::NoServerDisplay).
// Only the in-process display server installs one.
//
// THE WINDOW IS OPAQUE HERE. It is a `void*` with three hooks - acquire and release (Android:
// ANativeWindow_acquire / ANativeWindow_release) and a geometry request (the native -> Java
// up-call: SurfaceHolder.setFixedSize(w, h) for w, h > 0, setSizeFromLayout() for 0/0). That is
// what lets the host unit tests drive attach / wait / detach with a fake handle, and it keeps this
// file free of any platform header.
//
// THE LEASE. The apply thread that substitutes the window into a backend takes a LEASE on it
// (AcquireFor) and names a hook that asks it to let go. Detach() - the surfaceDestroyed path -
// calls that hook and then BLOCKS, bounded, until the holder has released its backend surface and
// ended the lease (EndLease); only then is the window's reference released. That ordering is the
// GLSurfaceView contract: after surfaceDestroyed returns nothing may render into the window. A
// holder that does not answer in time does not get the window pulled out from under it - the
// reference is kept and released when the lease finally ends - and Detach returns TimedOut, by
// name, so the UI thread is never held longer than its bound.
//
// A LEASE THAT IS ENDING IS WAITED FOR, NOT REFUSED. A holder whose session is over - it latched a
// fault (a device loss among them) or is stopping - says so (MarkLeaseEnding) before its client can
// learn of it; its lease still lasts until its backend has let go of the window. A client that loses
// its session re-creates its window surface on a fresh session at once, typically while the old
// session's apply thread is still tearing its backend down: that request waits for the old lease
// to end, bounded by its own timeout, instead of being refused as LeasedElsewhere. A holder whose
// session goes on keeps the window exclusively, as before.
//
// SUSPEND, NOT END (screen off / lock). A holder whose backend can let go of the window and keep
// everything else - its contexts, objects and device - ends its lease with SuspendLease instead of
// EndLease and names a second hook: the next Attach of a NEW window calls it, so the holder can
// lease that window (AcquireFor) and rebuild its surface on it. The registration lasts until that
// AcquireFor succeeds or the holder cancels it (CancelResume, at the session's end).
//
// THREADS. Every method is thread-safe. The hooks are called from the thread that called the
// method: acquire from Attach's caller, release from Detach's / EndLease's caller, requestGeometry
// from the apply thread (AcquireFor), the lost hook from Detach's caller and the attached hook from
// Attach's (or SuspendLease's) caller, both WITH THIS OBJECT'S LOCK HELD - so they must not block and
// must not call back into this object (ServerLoop's set an atomic and ring its doorbell).
// The server's own on-screen window, held process-wide between the UI thread that owns its
// lifecycle (Attach / Detach on surfaceCreated, surfaceChanged, surfaceDestroyed) and the apply
// thread that renders into it under a lease (AcquireFor / EndLease). Only the in-process display
// server calls Install(); elsewhere a ServerOwned surface is refused (NoServerDisplay). The window
// is an opaque `void*` driven through ServerDisplayHooks, so no platform header is needed here.
// Nothing may render into the window after surfaceDestroyed returns, so Detach asks the holder to
// let go and waits, bounded, for EndLease; on timeout it returns TimedOut and EndLease releases
// the window later. A lease marked ending is waited for, not refused. See ARCHITECTURE.md §15.2.
#pragma once
#include <Includes.h>
+46 -221
View File
@@ -6,66 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The apply thread and the server's private backend object. Owner: package v1 - the highest
// risk item in P5. Signatures by c0.
//
// WHY THE THREAD IS THE POINT. DirectGLES has 16 IsBackendContextCurrentOnThisThread() guards
// (DirectGLES.cpp:12034..12428) and Managers.cpp has 16 CanTouchGLNow() guards (:1494..:3428);
// every one of them DEGRADES when the answer is false - fences become always-signaled, queries
// return null handles, Present creates no frame fence so the buffer pool's recycle watermark
// never advances, and the two persistent-map acquisitions (Managers.cpp:1494, :2170) DECLINE,
// which would make PersistentCoherentMapScenario unreachable. Making the apply thread the
// context owner for life turns all 32 of those answers true on the server and removes the
// whole degradation class at once. It is also exactly the shape P6's spawned server inherits.
//
// P5 BUILDS ONE THREAD, NOT TWO. No mgl-srv-io: inproc's control plane is in the same process.
// P6 splits it.
//
// PARKING AND SHUTDOWN. The thread parks on Doorbell::Wait(consumerParked, ready, spinUs,
// kWaitForever) and shuts down when Wait returns false with Dead() set. TWO things can un-park a
// kWaitForever waiter, not one, and the difference is the whole of review m-4: for the STOP case a
// plain Doorbell::Notify() is sufficient, because m_stopRequested is in the park predicate and
// Stop() publishes it BEFORE it rings (Doorbell.h re-tests ready() after every Park return);
// Doorbell::Kill() (Doorbell.h, CondVarDoorbell::Kill) is load-bearing only for a predicate
// that has NOTHING to see, which is why the redcheck's park-predicate-loses-control entry - the one that drops the
// control flag FROM the predicate - is the case that actually times out. Kill BEFORE join; join
// before the client frees any emitter-owned Vector; and the join must be bounded (that test uses
// 5 s) so a regression is a red test and not a hung CI job.
//
// THE EGL OWNERSHIP MOVE, AS MEASURED (ID-54; review v2 item 10 and N-3). The native
// eglMakeCurrent for a surface runs ONCE PER CONTEXT LIFETIME on this thread and the context is
// then held for life. That "once" lives in TWO layers, because one is not enough:
// DirectGLES::MakeCurrent always calls native eglMakeCurrent and rewrites the owner (codex C7),
// and it is reached twice per bring-up - once from InitPbufferSurface/InitWindowSurface when the
// surface is CREATED, and once more from the client's first eglMakeCurrent. So (1)
// ServerMakeEGLCurrent classifies the request against the tuple it last bound
// (ClassifyEglMakeCurrent): an identical repeat is a no-op, and the R-12 republish decision for
// it is "nothing to republish" (ID-67: the client's mirror generation must not move); a different
// tuple is a real forwarded bind AND a caps republish; a client release-current is RECORDED
// (ClientReleaseCount) and NOT forwarded. And (2) BackendObject_DirectGLES::MakeEGLCurrent, under
// an active transport only, skips the native call when the requested draw surface is the one
// already natively current on this thread (IsBackendContextCurrentOnThisThread, which is EGL
// ground truth) AND the virtual tuple is the one that bind was for - or the bind is the surface's
// own creation, which the first tuple adopts; a different virtual context onto the same surface
// binds natively again, because MakeCurrent's invalidations describe the frontend context that
// changed (ID-67). Measured at the EGL function
// table by ServerLoopTest's C7 control on a real llvmpipe context: surface creation + two
// identical make-currents + a client release + a bind after the release = ONE native
// eglMakeCurrent, ZERO native releases, and the apply thread still the owner afterwards. So
// DirectGLES.cpp's six cache invalidations run once per context lifetime rather than once per
// client make-current, and the 16 IsBackendContextCurrentOnThisThread() / 16 CanTouchGLNow()
// sites answer TRUE on the server. The tuple is FORGOTTEN (N-3) on every event after which the
// native context it names may be gone - ReleaseEGLResources, ReleaseEGLSurface of the surface it
// names, a surface (re)creation, backend destruction - so a recycled handle value after a destroy
// is a real bind again and never a silent no-context-current. The client's nine EGL virtuals
// become BLOCKING control requests executed here. ReleaseEGLResources and
// ~BackendObject_DirectGLES MUST be blocking: MobileGL::Destroy() (MobileGL/Init.cpp:68)
// otherwise walks on while the server still holds the context.
//
// THE FALLBACK IS PRE-DECLARED, NOT INVENTED UNDER PRESSURE (R-1). If the context migration is
// still not running ClearThenReadPixels at the end of v1's fourth working day, the integrator -
// not the package - declares `inproc-inline`: the client thread drains the ring itself, no
// thread is created, no context migrates, and a second package picks up the thread arm.
// The apply thread (mgl-srv-apply) and the server's private backend object. The apply thread
// owns the backend's native EGL context for the context's whole life, so the backend's
// IsBackendContextCurrentOnThisThread() / CanTouchGLNow() guards answer true on the server
// instead of degrading. The client's EGL calls reach it as blocking control requests (the
// Server* forwarders below); ARCHITECTURE.md §15.5 has the make-current dedup and teardown rules.
// Stop() publishes m_stopRequested, which is in the park predicate, before it rings, so a plain
// Notify un-parks the thread; the join is bounded so a lost wakeup fails instead of hanging.
#pragma once
#include <Includes.h>
@@ -85,60 +32,14 @@
namespace MobileGL::MG_Remote::Server {
// ---------------------------------------------------------------------------------
// THE APPLY-THREAD IDENTITY, AS ONE INTEGER (P5d round 3, package D)
// ---------------------------------------------------------------------------------
//
// OnApplyThread() is the predicate under EVERY role guard on both hot paths -
// RefuseLegacyBufferArmFromApplyThread in BufferObject's accessors, MG_Record::PersistentMapTracker::
// OnServerRole, ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt, RunsAsTheServerRole,
// MGPipeRefuseAllocatorFromApplyThread, RefuseLegacyTextureArmFromApplyThread. simpleperf at
// head 56a77348 (FCL + Minecraft, VD12, Adreno 830) measured it at 2.66% self / 3.64%
// inclusive ON THE CLIENT THREAD, for an answer that is "no" every single time.
//
// The old body paid five times over for that "no": ServerLoopInstance()'s function-static
// guard byte, an acquire load of m_running, an acquire load of an atomic<std::thread::id>,
// std::this_thread::get_id(), and a thread::id comparison that is an out-of-line
// pthread_equal through the PLT (@plt was 6.1% of the apply thread). None of it carries
// information the guard needs. A live thread's identity is already sitting in a
// register-cheap place - the thread pointer, TPIDR_EL0 on aarch64 - so the loop publishes
// ITS identity as a plain integer here and every caller compares one relaxed load against
// one register read, inline, with no call and no singleton.
//
// ZERO MEANS "NO APPLY THREAD IS RUNNING", which is exactly the answer m_running == false
// used to give: the key is published as the apply thread's FIRST act (ApplyThreadMain) and
// cleared in the same m_controlMutex critical section that clears m_running on the way out
// (ApplyThreadMain's C2 exit block), so "key != 0" and "m_running" move together and the
// window in which a live-but-exiting apply thread answers true is byte-for-byte the old
// one. The ServerLoop object still OWNS the key - nothing but ApplyThreadMain writes it -
// it simply no longer has to be reached through the singleton to be read.
// P14 S2: AND THE ANSWER IS PER THREAD NOW, NOT PER PROCESS. One word cannot hold N apply
// threads: two sessions' threads would each publish their own key and the second store would
// make the FIRST thread answer "no" to every role guard it owns - which is not a slower guard
// but a dead one (a draw would take the client arm on the apply thread, and
// RunSurfaceControlFrame would lose its re-entrancy shortcut and deadlock the EGL teardown).
// The question is "am I an apply thread", a fact about the CALLER, so it is asked in the
// caller's own TLS - one constant-initialized word, no guard, no atomic:
//
// t_onApplyThread the calling thread IS an apply thread.
// g_applyThreadKey HOW MANY are running in this process. Kept because
// MG_Backend/DirectGLES/Managers.cpp (outside this package's file set)
// reads this name `!= 0` - "an apply thread is running somewhere" - which
// is the count's zero-ness exactly. It is no longer a key and nothing on
// the hot paths reads it.
//
// ZERO MEANS "NO APPLY THREAD IS RUNNING" still: t_onApplyThread is raised as the apply
// thread's FIRST act and lowered in the same m_controlMutex critical section that clears
// m_running on the way out, so the window in which a live-but-exiting apply thread answers
// true is byte-for-byte the old one.
//
// WHAT WENT WITH IT, so nobody re-derives it: the aarch64 TPIDR_EL0 fast path, the
// std::thread::id fallback with its no-padding static_assert, the MOBILEGL_DETAIL_
// APPLY_THREAD_POINTER build knob and the relaxed-load argument about a recycled TLS block.
// They existed to make "is the process's one apply thread mine" cheap; the question is now
// "is the caller an apply thread", whose cheap answer is the caller's own word - and a word
// cannot be recycled, because the thread that inherits a dead apply thread's TLS block is
// not an apply thread until it runs ApplyThreadMain.
// The apply-thread identity. OnApplyThread() sits under every role guard on the per-draw hot
// paths, so it is one inline read of the caller's own TLS word: no call, atomic or singleton.
// It is a per-thread fact because a process runs one apply thread per served session.
// t_onApplyThread the calling thread is an apply thread. ApplyThreadMain raises it before
// it applies anything and lowers it in the m_controlMutex section that
// clears m_running.
// g_applyThreadKey how many apply threads this process runs; Managers.cpp reads `!= 0` as
// "an apply thread is running somewhere". No hot path reads it.
namespace Detail {
// A fresh thread's TLS word is zero-initialized, so a thread that inherits the exited
// apply thread's TLS block - the recycling case the old relaxed-load argument was about -
@@ -152,27 +53,13 @@ namespace MobileGL::MG_Remote::Server {
inline std::atomic<Uint64> g_applyThreadKey{0};
} // namespace Detail
// ---------------------------------------------------------------------------------
// P12 (on-screen server window), D4: ONE SURFACE MODE PER SESSION
// ---------------------------------------------------------------------------------
//
// "Only one of the two rendering paths is active at a time, decided at context creation": a
// session's mode latches at its FIRST successful surface creation - a ServerOwned window makes
// it on-screen, a pbuffer makes it offscreen - and a later surface of the OTHER kind in the same
// session is refused by name (SurfaceRefusal::SurfaceModeMismatch: reply ok=false, logged, the
// session NOT latched). Windows the client names itself (the X11/Win32/None tokens CONTRACT-P6
// D8 has yet to close) are neither kind and neither latch nor are refused: nothing about them
// changes here. Reset by ServerLoop::Start, i.e. per session.
//
// P14 S2: AND THE LATCH IS PER SESSION, WHICH IS ALL THIS SLICE NEEDS. The mode is a member of
// the loop and a served session owns its loop, so two sessions in one process latch
// independently and neither's first surface decides the other's - the semantics above are
// otherwise unchanged (design/11-state-ownership.md §4.5: the latch stays at the session layer
// and is never hoisted to the process). A mode DECLARED before a session's first surface would
// have to travel on the wire - the Hello, or a SurfaceOp the client sends - because one
// process's MOBILEGL_IPC_SURFACE cannot speak for the several sessions a unified server runs;
// that is P14d's. Reading that env here instead refused a dialled-in ServerOwned client with
// SurfaceModeMismatch before the display was ever consulted (ServerOwnedSurfaceTest).
// One surface mode per session. It latches at the session's first successful surface
// creation: a ServerOwned window makes it on-screen, a pbuffer offscreen. A later surface of
// the other kind is refused with SurfaceModeMismatch (reply ok=false, nothing latched).
// Client-named windows (X11/Win32/None) and the surfaceless stand-in pbuffer neither latch
// nor are refused. The mode is a loop member reset by ServerLoop::Start, so each session
// latches on its own; it is not read from MOBILEGL_IPC_SURFACE, because one process's
// environment cannot speak for every session a unified server runs.
enum class SessionSurfaceMode : Uint8 { None, OnScreen, Offscreen };
const char* SessionSurfaceModeName(SessionSurfaceMode mode);
// The decision, pure, so a unit case can drive all six pairs without a backend.
@@ -221,30 +108,14 @@ namespace MobileGL::MG_Remote::Server {
// two files, and it is why no thread-keyed shim is needed for MOBILEGL_BUILD_DISAGGREGATED_INPROC.
MG_Backend::BackendObject* Backend();
// Run one blocking control request on the apply thread and wait for it. This is how all
// nine EGL lifecycle virtuals cross; it is deliberately NOT a queue of async messages,
// because every one of them has a return value the caller acts on immediately.
//
// THE REQUEST IS A VALUE FRAME (P5f, package fc). It used to be a raw function pointer
// plus a void* to a stack-local Args struct - neither has a meaning across a process
// boundary, which is the whole of P5c audit row G4. Now the slot carries one
// SurfaceControlFrame BY VALUE: the op's identity is the frame's `kind`, its arguments
// are scalars, and its reply fields come back in the same value when the wait returns.
// What did NOT move: the blocking handshake, the one slot, and the apply-thread
// ownership. Under spawn the same frame is what SurfaceOpCodec encodes into a
// Wire::SurfaceOp - this signature is the seam both transports share.
// P6 `cp`: where a control frame goes when the server is ANOTHER PROCESS.
//
// All twelve Server* forwarders funnel through RunSurfaceControlFrame, so
// this is ONE seam rather than twelve (a6 row A4-13 counted them). The
// client installs a sink at handshake; with none installed the behaviour
// is exactly what it was - post into the one-slot mailbox, or run inline
// when we are already on the apply thread.
//
// A HOOK RATHER THAN A CALL INTO ClientSession, deliberately: the server
// half calling the client half is four of the six symbols a6 found
// MG_Remote owes itself (a6-link-experiment §5), and this is the one
// place that would have added a fifth.
// Runs one blocking control request on the apply thread and waits for it; every Server*
// EGL forwarder funnels through here. Blocking rather than async, because each caller
// acts on the reply at once. The frame crosses by value: `kind` names the op and the
// reply fields come back in the same frame; under spawn SurfaceOpCodec encodes it as a
// Wire::SurfaceOp. When the server is another process the client installs a
// RemoteControlSink at handshake; with none, the frame goes to the one-slot mailbox, or
// runs inline when already on the apply thread. A hook rather than a call into
// ClientSession, so the server half does not depend on the client half.
using RemoteControlSink = MobileGLResult (*)(void* user, SurfaceControlFrame& frame);
void SetRemoteControlSink(RemoteControlSink sink, void* user);
@@ -388,31 +259,14 @@ namespace MobileGL::MG_Remote::Server {
void ForgetCurrentTuple();
void ForgetCurrentTupleIfItNames(EGLSurface surface);
// 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.
//
// IT READS ONE ATOMIC AND TAKES NO LOCK (P5d round 3, package T item 1). It used to
// take m_controlMutex on every call, and the idle poll calls it on every iteration -
// once inside the `ready` lambda and once more through PumpControlRequest - which
// simpleperf at head 56a77348 measured as ~49% of the whole apply thread's cycles
// (mutex::lock 12.4 + mutex::unlock 13.6 + the two pthread_* halves, 58-60% of them
// with ApplyThreadMain as the DIRECT caller). An idle poll on an empty ring must cost
// loads, not a futex-backed critical section. m_controlPosted is that load; the
// mailbox's own fields stay under the mutex and the handshake below is unchanged.
//
// PUBLIC because ServerLoopTest reads it: the clear side of the shadow has a failure
// mode - a shadow left set makes `ready` permanently true and the loop never parks
// again - that no other public surface can see.
//
// READ THE PREDICATE LITERALLY: "a request is POSTED AND NOT YET TAKEN", which is
// narrower than the name's "a control request is in flight". The shadow is cleared
// when the pump takes the work, not when the work returns, so for the whole duration
// of `work(user)` this answers false although m_controlPending is still true and the
// poster is still blocked in RunSurfaceControlFrame. That is deliberate - it is a PARK
// PREDICATE, and the thread that would act on it is the one running the work - but it
// means this is not a liveness query and must not be used as one. The only in-tree
// reader outside the test is the apply thread's own `ready` lambda, which by
// construction cannot be inside that window.
// Part of the apply thread's park predicate: a posted control request must un-park a
// thread waiting on kWaitForever, which a Notify alone cannot do. It reads only
// m_controlPosted, without the lock, because the idle poll calls it on every iteration and
// a mutex here cost about half the apply thread's cycles. It means "posted and not yet
// taken": the pump clears it when it takes the work, so it reads false while the work runs
// and the poster still waits. Not a liveness query; the only reader is the apply thread's
// `ready` lambda. Public so ServerLoopTest can catch a flag left set, which would stop the
// loop from ever parking.
Bool ControlIsPending() const;
// ---- P12 (on-screen server window), D3/D6 -------------------------------------------
@@ -672,43 +526,14 @@ namespace MobileGL::MG_Remote::Server {
// The supervisor announces it at start-up; the server app's broker relays it to the helper.
Uint64 ReservedApplyCoreForThisProcess(const char** outRule);
// ---------------------------------------------------------------------------------
// 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. The native eglMakeCurrent then runs once per context lifetime on that
// thread (the surface's own creation binds; the client's make-currents onto that surface
// are deduped at both layers, header block above) and a client release is never forwarded,
// so g_backendContextOwnerThread is written once per context lifetime, DirectGLES.cpp's six
// cache invalidations run once per context lifetime rather than once per client
// make-current, the per-frame EGL re-verification stamp holds, and the 16
// IsBackendContextCurrentOnThisThread() sites plus the 16 CanTouchGLNow() sites answer TRUE
// on the server instead of silently degrading. Measured, not assumed: ServerLoopTest's C7
// control counts the driver's eglMakeCurrent calls at the EGL function table.
//
// 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.
//
// P5f (package fc): each of the twelve packs its arguments into a SurfaceControlFrame and
// posts it through RunSurfaceControlFrame - the wire-shaped value channel that replaced the
// function-pointer mailbox (P5c audit row G4). Nine of them map onto the ten wire ops of
// protocol.fbs's SurfaceOp; ServerSwapEGLBuffers, ServerInitCapabilities and
// ServerInitWindowSurface ride the same channel as inproc-only kinds (present travels as a
// record, capabilities are answered by the CapsSnapshot frame, and the third has no caller -
// f0-egl's census, findings F8/§4.2).
// The EGL control forwarders. BackendObject_Remote's EGL virtuals call these, so the app
// thread never reaches the driver's eglMakeCurrent: each packs its arguments into a
// SurfaceControlFrame and blocks in RunSurfaceControlFrame while mgl-srv-apply runs it against
// the server's backend (ARCHITECTURE.md §15.5). ServerReleaseEGLResources must block, and so
// must ~BackendObject_DirectGLES, which ServerLoop::Stop() runs on the apply thread before it
// exits; otherwise teardown runs on while the server still holds the context. Free functions,
// because the client needs only these calls. SwapBuffers and InitWindowSurface are
// inproc-only frame kinds with no wire op: present travels as a record.
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);
+30 -109
View File
@@ -361,26 +361,14 @@ void DelaySessionHandshakeForTest() {
std::this_thread::sleep_for(std::chrono::milliseconds(std::min<long>(ms, 60000)));
}
// Called only after fork (or, in the single-session shape, instead of it): the supervisor never
// creates a backend or EGL context. `dataSource` is set on TCP (PH-7 (4)) and empty on a unix
// endpoint, whose second connection is the SCM_RIGHTS socket PairAcceptor already paired.
// `sourceFd` is the descriptor that source reads from - the child's end of the hand-off
// socketpair under --serve, the listener itself in the single-session shape - and -1 when there
// is none; it is let go of the moment Accept returns, and on every exit before that
// (ExitBeforeAccept, ID-P7-44).
//
// `helloFrame` is the first frame when somebody has already read and authenticated it - the TCP
// supervisor always has (PH-7 (5)) - and empty when this function reads it itself, within
// `helloWaitMs`.
//
// P12 (D5): IT RETURNS THE SESSION'S EXIT CODE, and never exits. A forked child and a
// single-session process pass the code straight to ExitSessionProcess - so every one of those shapes
// ends with the `_exit` code it always had - while `inProcess` (the in-process display server's
// session thread) returns it to the supervisor thread and the process lives on. What a process exit
// used to clean up for free, `inProcess` cleans up by hand on every way out: the backend (the loop's
// Stop drops one no thread owns), the session singleton, the stats window, the log forwarder and the
// progress sink, both of which point at `control`. The latch is reset by the supervisor between
// sessions (ResetSessionLatch).
// Runs one served session; the supervisor never creates a backend or EGL context. `dataSource` is
// set on TCP and empty on a unix endpoint, whose aux connection PairAcceptor already paired.
// `sourceFd` (the hand-off socketpair end under --serve, the listener in the single-session shape,
// or -1) is released when Accept returns and on every earlier exit (LeaveBeforeAccept).
// `helloFrame` holds the first frame if the caller already read and authenticated it; otherwise it
// is read here within `helloWaitMs`. Returns the session's exit code and never exits; with
// `inProcess` (a session thread of the in-process display server) every way out cleans up by hand
// what a process exit would, and the supervisor resets the latch between sessions.
int RunSession(std::unique_ptr<SocketTransport> control, std::vector<std::uint8_t> helloFrame,
Server::ServerSession::DataConnectionSource dataSource, int sourceFd, DataSource sourceKind,
std::uint32_t helloWaitMs, bool inProcess = false) {
@@ -481,23 +469,13 @@ int RunSession(std::unique_ptr<SocketTransport> control, std::vector<std::uint8_
MobileGL::MG_Config::Transport = MobileGL::MG_Config::TransportMode::Spawn;
MobileGL::MG_Pipe::MGPipeSetServerProcessRole(true);
});
// PH-1 (3), ID-P7-1: THIS PROCESS IS THE SESSION, so a named fault on the peer's bytes may end
// it cleanly instead of aborting it (FatalFunnel.h's latch block). Armed here and nowhere
// else: inproc keeps every Fatal (client and server share that process), and the supervisor
// that forked this child never applies a record.
//
// P12 (D5): and the in-process display server arms it for EVERY session, here, the same way.
// P14 S2: "IT" IS PER SESSION NOW - the scope below binds this thread to this session's latch
// domain (FatalFunnel.h), so the arm that follows arms THAT domain and no other: a session
// that latched cannot decline its neighbour, and the supervisor's ResetSessionLatch touches
// only its own (process-default) domain.
// P14 S2: THIS SESSION'S OWN RUNTIME, AND BOTH OF ITS THREADS ON IT. The object holds the
// session, the apply loop and the PipeInputs block; the scope makes them what this thread's
// reads of ServerSessionInstance()/ServerLoopInstance()/gPipeInputs resolve to, and binds this
// session's latch domain and segment resolver. Everything below - the backend bring-up, the
// arm, the EGL frames - therefore runs against THIS session, which is what lets one process
// serve several at once. The apply thread opens the same scope as its first act
// (ServerLoop::ApplyThreadMain, via SetRuntime below).
// A served session's process (or in-process thread) is the session, so a named fault on the
// peer's bytes may end it cleanly instead of aborting (FatalFunnel.h's latch); inproc keeps
// every Fatal, and the supervisor applies no record. SessionRuntime holds this session's
// session object, apply loop and PipeInputs block; the scope binds this thread's
// ServerSessionInstance(), ServerLoopInstance(), gPipeInputs, latch domain and segment
// resolver to it, so ArmSessionLatch arms this session's domain only. The apply thread opens
// the same scope first (ServerLoop::ApplyThreadMain).
auto runtime = std::make_unique<Server::SessionRuntime>();
Server::Detail::ThreadSessionScope threadScope(*runtime);
ArmSessionLatch();
@@ -705,30 +683,12 @@ int RunSession(std::unique_ptr<SocketTransport> control, std::vector<std::uint8_
WireLogError("MG_Remote server: pid=%d transport=spawn role=server ready control=%s data=%s",
selfPid, tcp ? "tcp" : "unix", tcp ? "stream" : "shm");
std::vector<std::uint8_t> buffer(64 * 1024);
// PH-6 (ID-P7-2): THE CONTROL WAIT IS BOUNDED, SO A SESSION CAN END FROM THE SERVER'S SIDE.
// It used to be kWaitForever, which left exactly one way out: the peer closing its control
// connection. A peer that forfeited the reverse channel is by definition one that stopped
// reading, and it may well keep that connection open - so the apply thread would stop and
// this child would sit here, holding the supervisor's one session slot, answering every
// later client Busy. Now each quiet interval asks whether the apply thread is still there;
// when it is not (forfeit, or a dead data bell), the session takes the ordinary exit below:
// Stop, Close, _exit(0). (PH-6 used the supervisor's 250 ms accept poll as the interval; the
// PH-1 note below says why the slice is now 100 ms.)
//
// ASKED AT THE TOP OF EVERY TURN, NOT ONLY WHEN A WAIT TIMES OUT (PH-6 fix round). A peer that
// forfeited the reverse channel but keeps TALKING - a LogFlush or a surface op more often than
// every 250 ms, each of which this loop answers - never let ReceiveFrame time out, so the
// question was never asked and the session, with the supervisor's one slot, lived as long as
// the peer kept chattering.
//
// PH-1 (3): the same wait is also how a LATCH raised on the apply thread (the decoder, the
// applier, DrainRing) ends this loop within one slice while the peer is silent. ReceiveFrame
// keeps a partial frame in its reassembler across a timeout, so slicing loses no bytes; a
// latched session is then closed below with kSessionLatchedExitCode. The latch is asked
// BEFORE the apply thread's liveness because a latch also stops that thread (it leaves its
// loop and clears m_running), and the session must be named by its latch, not as a stopped
// apply thread. One slice serves both: the latch's 100 ms (PH-6 had 250 ms; the shorter wait
// only asks the question more often).
// The control wait is sliced so a session can end from the server's side. Each turn first asks
// whether the session latched (a fault on the apply thread, which also stops that thread, so
// the latch is asked first and names the end), then whether the apply thread still runs (it
// stops on a forfeited reverse channel or a dead data bell). Both are asked every turn, not
// only on timeout, so a peer that keeps sending frames cannot hold the supervisor's session
// slot. ReceiveFrame keeps a partial frame across a timeout, so slicing loses no bytes.
constexpr std::uint32_t kControlSliceMs = 100;
for (;;) {
if (SessionLatched()) break;
@@ -765,23 +725,10 @@ int RunSession(std::unique_ptr<SocketTransport> control, std::vector<std::uint8_
static_cast<unsigned long long>(control->BufferedBytes()));
break;
}
// THE FILE IDENTIFIER, ASKED FIRST AND SAID OUT LOUD (P7 wave 0).
//
// The plan asked for CtrlEnvelopeBufferHasIdentifier here because ServerSession::
// ParseEnvelope (:109) asks it and this loop did not. MEASURED, THE PREMISE IS WRONG:
// the generated VerifyCtrlEnvelopeBuffer is
// `verifier.VerifyBuffer<CtrlEnvelope>(CtrlEnvelopeIdentifier())`
// (protocol_generated.h:2378), so a wrong identifier was ALREADY refused - and so is
// ParseEnvelope's second call. Recorded for the integrator: the gap that row names does
// not exist.
//
// What DID exist is that both refusals were silent. A peer whose post-Welcome frame was
// garbage got its session ended with no line in the log at all, which is the half of a
// fuzz arm that matters - an arm that cannot tell "refused, by name" from "the child
// went away" measures nothing. So the identifier is now asked FIRST, because it is the
// specific diagnosis ("that is not our schema") where the verifier's is the general one,
// and both say so. Asking it first needs the length guard the verifier would otherwise
// have provided: BufferHasIdentifier reads bytes [4, 8).
// The identifier is checked before the verifier, which would also reject a wrong one, so
// the log names the specific cause; every refusal here is logged, because a silent end
// cannot be told apart from a crashed child. BufferHasIdentifier reads bytes [4, 8),
// hence the length guard.
if (size < 8) {
WireLogError("MG_Remote server: control frame is %llu bytes, too short to carry a "
"CtrlEnvelope root and identifier; ending the session",
@@ -926,37 +873,11 @@ void LogSupervisorSummary(unsigned long sessionsFaulted) {
static_cast<int>(::getpid()), sessionsFaulted);
}
// PH-7 (5), ID-P7-3, ph-f.md §6.4, CONTRACT-P7 §12. THE TCP SUPERVISOR: AUTHENTICATE, THEN FORK.
//
// Until this package the --serve loop accepted a TCP connection and forked a session child for
// it before a byte had been read; the child read the Hello (10 s budget), checked the wire and
// only then the token. So anybody who could reach the port cost the server a fork per connection
// and a child held for up to 10 s, and an unauthenticated Hello with a wrong fingerprint was
// answered with this build's fingerprint. Now:
//
// - every new connection is PENDING until its first frame is complete. The supervisor reads it
// itself, a few bytes per wakeup, from all pending connections at once (one poll loop - no
// unauthenticated peer can make it wait for another; the one wait left is up to 50 ms for an
// exiting session before an AUTHENTICATED Hello is told Busy), and never a byte past the frame;
// - a pending connection has MOBILEGL_IPC_PREAUTH_MS (default 2000) to complete it, and at most
// MOBILEGL_IPC_PREAUTH_MAX (default 8) are pending at once. The queue is SHARED between
// addresses: when it is full a newcomer displaces the oldest connection of the address holding
// the most slots, and is itself refused Busy only when its own address already holds as many;
// - a Hello is AUTHENTICATED HERE, token first; an unauthenticated one is answered
// Refuse{Authentication} and nothing else. Only an authenticated Hello is forked for, and the
// child is handed the frame it would otherwise have read;
// - a failure (wrong token, malformed first frame, deadline, a silent connection that held its
// slot past kPreAuthProbeGraceMs before it closed or was displaced) is counted against the
// peer's address; after MOBILEGL_IPC_AUTH_BACKOFF_AFTER (default 5) of them that address is refused
// at accept, before a byte is read, for MOBILEGL_IPC_AUTH_BACKOFF_MS doubling per further
// failure up to a minute. An authenticated Hello from the address clears it;
// - a DataBind is routed exactly as PH-7 (4) routed it: to the live session's child over the
// hand-off with the nonce as sideband (the child compares), or refused by name when there is
// no live session - here now, instead of in a child forked to say so.
//
// What is unchanged: one session at a time (an AUTHENTICATED second client is Busy), the child's
// own ValidatePeerHandshake / Accept / BindDataConnection, the hand-off and its refusals, the
// reap lines and sessionsFaulted.
// The TCP supervisor reads every new connection's first frame itself without blocking on any
// one peer, authenticates a Hello (token first) and only then hands the session off (fork or
// thread); it routes DataBinds to the live session and backs off failing addresses
// (ARCHITECTURE.md §15.1.1, PreAuthGate.h). One session at a time: an authenticated second Hello
// is answered Busy.
class TcpSupervisor {
public:
using Clock = std::chrono::steady_clock;
+14 -41
View File
@@ -46,25 +46,12 @@
namespace MobileGL::MG_Remote::Server {
namespace {
// F1 (P7 wave 2). THE ONLY KNOB THAT CAN MAKE THE FIRST CapsSnapshot LATE.
//
// MOBILEGL_TEST_DELAY_FIRST_CAPS_MS delays the server's FIRST publication, and only
// it - a re-send (R-12) is never delayed, because the question is about the window
// before the client has any mask at all. Read once, on the first Accept, so a lane
// that does not set it pays one getenv per session and nothing else.
//
// WHAT IT PROVES, AND WHERE. Under spawn / unix: / tcp:// the publication happens in
// the SERVER's process, so the delay is a genuinely late snapshot from the client's
// point of view and it exercises the client's step-7 wait (ClientSession.cpp:1284).
// Under inproc there is no second process: Accept runs inside ClientSession::Start on
// the client's own thread, so the delay simply makes Start slower - which is itself
// the proof that under inproc the first snapshot CANNOT be late relative to session
// start, and therefore that the only window in which the client could read a
// placeholder is the one BEFORE Start is called. That window is F1's defect, and
// MobileGL::Initialize closes it by ordering rather than by waiting.
//
// It is a TEST knob: it exists only in a disaggregated build and does nothing at all
// unless it is set, so no production path can reach a delay it did not ask for.
// MOBILEGL_TEST_DELAY_FIRST_CAPS_MS: a test-only knob (disaggregated builds, inert unless
// set) that delays the server's first CapsSnapshot only; re-sends are never delayed. Read
// once per process. Across processes it makes the snapshot genuinely late and exercises the
// client's wait for it; under inproc Accept runs inside ClientSession::Start, so it only
// slows Start, showing the first snapshot cannot be late relative to session start there
// (the window before Start is closed by ordering in MobileGL::Initialize).
void DelayFirstCapsSnapshotForTest() {
#if MOBILEGL_BUILD_DISAGGREGATED
static Bool spent = false;
@@ -228,28 +215,14 @@ namespace MobileGL::MG_Remote::Server {
eventName, static_cast<unsigned long long>(payloadBytes));
}
// ---- P5e (ra), CONTRACT-P5E §2.6: the ring stops being a Fatal and becomes a queue --
//
// THE FATAL'S PREMISE DIES WITH THE LOCKSTEP. "A full ring is a producer burst no
// workload has" was true because the client drained at EVERY verb: the ring was empty
// whenever the server started a record, so filling 256 KiB inside one apply meant a
// defect. A run-ahead client drains at its waits, which on the steady path are one
// present apart, so a full ring is now an ordinary backlog and aborting on it would
// turn the reverse channel's capacity into a workload limit.
//
// SO THE PRODUCER BLOCKS. It publishes what is already in the ring and rings the
// client (a client that has not yet noticed cannot drain), then parks on its own bell
// until the client's Drained() clears the latch and rings back. The deadlock argument
// is the contract's, and it needs BOTH halves: the server blocks only here, the client
// blocks only on watermarks the server advances, and every client park breaks on
// `eventRingFull` as well (SessionProducer::WaitFor*OrEventBacklog) - so a parked
// client is always wakeable by the server that is waiting for it.
//
// ONE NAMED REFUSAL SURVIVES, the impossible case: a single event larger than the ring
// can EVER hold (no amount of draining helps). The two BUSY refusals that used to sit
// beside it - a wait that ran out of patience, and two rounds against a ring the record
// fits - are the PEER's behaviour and became the forfeit latch below (PH-6, ID-P7-2).
// A server that does not publish kCapRunAheadApply keeps P5C's Fatal unchanged.
// With run-ahead the client drains SEG_EVENT only at its waits, so a full event ring is an
// ordinary backlog: the producer publishes what it has, rings the client and parks on its
// own bell until the client's Drained() clears the latch and rings back. No deadlock: the
// server blocks only here, and every client park also breaks on `eventRingFull`
// (SessionProducer::WaitFor*OrEventBacklog). One event larger than the ring can ever hold
// is still Fatal; a client that does not drain forfeits the reverse channel
// (ServerSession::ForfeitReverseChannel). A server without kCapRunAheadApply keeps the
// overflow Fatal. See ARCHITECTURE.md §11.7.
Bool ServerPublishesRunAhead(const ServerSession& session) {
if (!session.CallMaskIsSet()) return false;
return (session.CallMask() & static_cast<Uint64>(MG_Pipe::kCapRunAheadApply)) != 0;
+23 -74
View File
@@ -6,38 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The server half of a session: the consumer side of the rings, the handshake reply, the caps
// snapshot. Owner: package s1. Signatures by c0.
//
// The four segment sizes are already pinned by ProtocolSmokeTest.cpp:72 and are not up for
// re-derivation here: SEG_CMD 8 MiB, SEG_STAGE 32 MiB, SEG_REPLY 8 MiB, SEG_EVENT 256 KiB.
// MOBILEGL_IPC_RING_MB and MOBILEGL_IPC_STAGE_MB move the first two; the ring caps ONE record
// at half its size (R-10). See SessionRings.h's header for why the ring inside SEG_CMD is 4 MiB
// rather than 8 - the control page takes the head and the capacity must be a power of two -
// and why that deviation from CONTRACT-P5 §5's arithmetic is in the safe direction.
//
// THE TWO DOORBELL ACCESSORS ARE ON THE CONCRETE CLASS, NOT ON ITransport
// (InProcessTransport.h:64-68). P5 decides this now rather than letting P6 discover it: the
// SESSION owns the pair and hands out references, so ITransport stays the dumb control-plane
// interface its header says it is and SocketTransport does not have to grow two accessors it
// has no natural home for. Discovering this in P6 would mean re-laying one package's call sites.
//
// WHO CREATES THE SEGMENTS: this side. Welcome announces all four SegmentRefs and Welcome is
// server -> client, so the server allocates and the client attaches. "Client-owned" in
// protocol.fbs's comments is about who WRITES a segment, not who allocates it.
//
// WHAT Accept() DOES, IN ORDER. The ABI assertion is FIRST, before a single record is decoded
// and before any segment exists, because its whole purpose is to refuse to interpret the peer's
// bytes at all:
// 1. receive Hello;
// 2. compare abiMajor/abiMinor and CapsAbiFingerprint() - Fatal{AbiMismatch} on a difference,
// never a downgrade;
// 3. create and map the four segments, initialise both control pages, clear the reply pool;
// 4. send Welcome with the four SegmentRefs;
// 5. publish the first CapsSnapshot, IF a backend has been handed over (SetBackend). Without
// one the session is accepted and the snapshot is deferred with a loud line: the caps
// blob codecs are w1's and the server's private BackendObject is v1's, and a session that
// refused to exist until both landed would block every other package's bring-up.
// The server half of a session: the consumer side of the rings, the doorbell pair (handed out
// here, not by ITransport, which stays control-plane only), the handshake reply and the caps
// snapshot. The server allocates the segments and the client attaches; "client-owned" in
// protocol.fbs means who writes a segment. Accept, in order: receive Hello; authenticate the peer
// and refuse any ABI, fingerprint or link-terms mismatch before any shared memory exists or any
// record is decoded; create the segments; send Welcome; bind the stream data connection, if any;
// install the segment resolver and the reverse-channel callbacks; publish the first CapsSnapshot
// if SetBackend was called, else defer it with a warning.
#pragma once
#include "../Transport/ILink.h"
@@ -146,30 +122,14 @@ namespace MobileGL::MG_Remote::Server {
// PublishCapsSnapshot yourself.
void SetBackend(MG_Backend::BackendObject* backend);
// MGPCaps::CallMask's two halves. BOTH ARE MANDATORY AND NEITHER HAS A DEFAULT.
//
// The first version of this file derived a default for each. That was the phase's
// marquee defect committed from the server's side: the consumer default read
// MGPipeGetResourceOps(), a PROCESS-WIDE global (PipeApply.cpp), so under inproc the
// server answered with whatever the client half of the same process had registered,
// and under spawn it collapsed to P2's 0x7f. Either way CapsMirror::ServerConsumes
// then answers a client-side liveness gate with a guess: the client stops emitting
// five P4a families, CLEARS ITS DIRTY FLAGS ON ACCEPTANCE ANYWAY, and the lane goes
// green with the uploads lost - ID-39's 66 lost uploads, reflected. R-8's whole point
// is that a client-side gate may never be answered by a server-side fact; a
// server-side gate answered by a PROCESS-wide fact is the same defect one level down.
//
// So there is no derivation at all. An unset mask is a programming error and
// CallMask() is a named Fatal on one - loud at the first snapshot instead of silent
// for a phase. Flagging it in a report was not a mechanism; this is.
//
// BITS 0..8, THE MGPCapBit FEATURE BITS. What each one answers belongs to the package
// that owns the question (kCapTimerQuery to the query family, kCapResidentSubData to
// b1, and so on). `SetCapabilityBits(0)` is a legitimate and explicit answer - "this
// server offers no optional capability" - and is the right call while those packages
// land. kCapNeedsHostIndexBytes / kCapNeedsHostUboBytes / kCapViewportArray are RESERVED
// (P8-F; CONTRACT-P5 table 0 first held the two host-byte bits at 0): the first two are
// the only things that ask for an MGHostSpan, and 0 keeps every one of them off the wire.
// MGPCaps::CallMask's two halves; both are mandatory and neither has a default. The client
// decides from the consumer bits which record families to emit and still clears its dirty
// flags on acceptance, so a guessed mask (a process-wide registration, a fixed value) loses
// uploads silently; CallMask() is Fatal{UnsetCallMask} until both setters have run. This
// one takes the MGPCapBit feature bits, each answered by the subsystem that owns it;
// SetCapabilityBits(0) is an explicit "no optional capability". kCapViewportArray and the
// two host-byte bits (kCapNeedsHost*Bytes, the only thing that asks for an MGHostSpan) are
// reserved and stay 0.
void SetCapabilityBits(Uint64 capBits);
// BITS 32..47, THE CONSUMER MASK (R-8 / C-4): which MGPipe subsystems this server has
@@ -236,24 +196,13 @@ namespace MobileGL::MG_Remote::Server {
Bool EventRingHasRoom() const;
Transport::ITransport* Control_Plane();
// ---- PH-6 (ID-P7-2): THE REVERSE CHANNEL IS FORFEITED, NOT FATAL -------------------
//
// A run-ahead producer that finds SEG_EVENT full parks until the client drains room
// (CONTRACT-P5E §2.6). What a client that does not drain used to cost was two Fatals
// inside ReserveEventOrBlock - "waited 30000 ms", twice, and "a drained ring that still
// refuses a record it fits is a corrupt cursor set", which a peer that drains one slot at
// a time reaches in milliseconds. Both are the PEER's behaviour, not a defect of this
// process, so neither may abort it.
//
// What replaces them is a LATCH on this session. The first event that cannot be placed
// within MOBILEGL_IPC_EVENT_WAIT_MS raises it (ForfeitReverseChannel, which logs
// `ReverseChannelForfeit{<cause>}` once); that event and every later one is a counted
// drop (CountDrop on the shared page, ForfeitDrops() here) into scratch the producer may
// fill and forget; the apply loop's park predicate sees the latch and takes the ordinary
// stop path; ServerMain's control loop sees the apply thread gone and ends the session.
// The process exits 0 and the supervisor welcomes the next connection. The latch is
// set, read and reset on the apply thread's side of Accept/Close only, and read with
// acquire elsewhere (the control loop, tests).
// Reverse-channel forfeit: a client that does not drain SEG_EVENT is the peer's fault and
// must not abort this process. The first event that cannot be placed within
// MOBILEGL_IPC_EVENT_WAIT_MS raises this session's latch (ForfeitReverseChannel logs
// `ReverseChannelForfeit{<cause>}` once); that event and every later one is a counted drop
// into scratch; the apply loop sees the latch and stops, and ServerMain ends the session
// with status 0, not as a fault. The latch is written only on the apply thread (reset by
// Accept) and read with acquire elsewhere. See ARCHITECTURE.md §11.7.
Bool ReverseChannelForfeited() const {
return m_reverseChannelForfeit.load(std::memory_order_acquire);
}
+12 -48
View File
@@ -77,34 +77,13 @@ namespace MobileGL::MG_Remote::Server {
return std::string();
}
// Every MOBILEGL_TRANSPORT and MOBILEGL_IPC_* goes. Built BEFORE fork:
// between fork and execve only async-signal-safe calls are allowed, and
// allocating a vector of strings is not one of them.
//
// EXCEPT A KNOB THE SERVER READS, named here one by one. P7 wave 4 M2's
// MOBILEGL_IPC_WIRE_DEFERRED_MB bounds the server's own orphaned wire buffer stores;
// nothing in the client reads it, so scrubbing it did not make the child safer - it made
// the knob mean something under inproc and silently nothing under spawn, the worst of
// the three states (the tcp server reads its supervisor's environment, as for every
// knob). It names no endpoint, no role and no segment size, so neither anti-recursion
// catch has anything to say about it.
//
// M2 round 2 (review): three more the server reads and that were silently defaulting in
// the spawned child for the same reason - MOBILEGL_IPC_SPIN_US (the doorbell spin budget,
// read by BOTH roles, so the child should spin as the parent was told to),
// MOBILEGL_IPC_SERVER_AFFINITY (the apply thread's CPU mask, read only by the server) and
// MOBILEGL_IPC_AUDIT (the server's 0xDD fill of retired stage bytes). Each is a tuning or
// audit value, none names an endpoint, a role, a path or a segment size, and ServerMain's
// catch (b) keeps verifying the four it verifies (TRANSPORT, SERVER_PATH, RING_MB,
// STAGE_MB). Everything else under the prefix still goes.
//
// PH-6 (ID-P7-2) adds MOBILEGL_IPC_EVENT_WAIT_MS on the same argument: it is the server's
// patience with a client that has stopped draining SEG_EVENT, nothing on the client reads
// it, and scrubbing it would make a spawned server wait the default whatever the lane set.
//
// P11 B2 adds MOBILEGL_IPC_ALLOW_ADOPT_T0, the server's own T0 allow switch: only the server
// reads it, and scrubbing it would make "allow switch off" unreachable on the spawn arm.
// MOBILEGL_IPC_ADOPT_TIER itself still goes - the client's ask travels in its Hello.
// Scrubs MOBILEGL_TRANSPORT and every MOBILEGL_IPC_* from the child's environment; built
// before fork, because allocating is not async-signal-safe between fork and execve.
// kServerOwned lists the exceptions, knobs the server itself reads (wire
// deferred budget, spin budget, apply-thread affinity, stage audit fill, event wait, T0
// allow switch): scrubbing them would silently default them under spawn. None names an
// endpoint, role, path or segment size, so ServerMain's environment catch is unaffected.
// MOBILEGL_IPC_ADOPT_TIER is scrubbed: the client's ask travels in its Hello.
bool ShouldScrub(const char* entry) {
static constexpr const char* kServerOwned[] = {
"MOBILEGL_IPC_WIRE_DEFERRED_MB=", "MOBILEGL_IPC_SPIN_US=", "MOBILEGL_IPC_SERVER_AFFINITY=",
@@ -149,26 +128,11 @@ namespace MobileGL::MG_Remote::Server {
// §3.1 is the whole argument for why those are two axes.
env.storage.emplace_back("MOBILEGL_IPC_ROLE=server");
env.storage.emplace_back("MOBILEGL_IPC_DIAL=no");
// LD_LIBRARY_PATH, BECAUSE THE SERVER IS EXEC'd AND NOT dlopen'd.
//
// It links libMobileGL.so, and an exec'd process gets a FRESH linker
// namespace with the default search path - not the one the app built
// for its own libraries. An APK's lib/<abi>/ is not on that path, and
// the failure is total and immediate:
//
// CANNOT LINK EXECUTABLE ".../libMobileGLServer.so":
// library "libMobileGL.so" not found: needed by main executable
//
// It is invisible on desktop, where CMake gives build-tree targets an
// absolute RPATH of their own - so the WSL server had been resolving
// its dependency through a path no phone has.
//
// $ORIGIN WOULD BE THE TIDIER ANSWER and the target asks for it, but
// the NDK toolchain drops RPATH from the link: measured with readelf
// on the APK's own copy, which carries NEEDED libMobileGL.so and no
// RUNPATH at all. So the launcher states it instead - it is the one
// component that has already resolved where the image lives, and a
// pair that is not co-located is not a pair.
// LD_LIBRARY_PATH: the server is exec'd, not dlopen'd, so the linker uses its default
// search path, which lacks the APK's lib/<abi>/, and libMobileGL.so is not found.
// Desktop builds hide this behind CMake's absolute build-tree RPATH. $ORIGIN would be
// tidier, but the NDK link drops RPATH (the APK's copy has no RUNPATH), so the
// launcher, which already resolved the image's directory, states it.
if (!imageDir.empty()) {
env.storage.emplace_back("LD_LIBRARY_PATH=" + imageDir);
}
+8 -29
View File
@@ -6,35 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// LAUNCHING a server process, and the process discipline around it.
// Package `sm` (CONTRACT-P6.md §3).
//
// IT LAUNCHES; IT DOES NOT COUPLE. The two processes are independent: this
// hands the child an endpoint NAME and nothing else - no inherited descriptors,
// no shared memory, no handshake. The child listens on that name and the client
// connects to it, exactly as it would to a server someone started by hand or
// that an Android Service started minutes earlier.
//
// An earlier shape of this file forked the server and handed it fds 3..6.
// Inheritance works and is less code, but it can only ever produce a server
// that is a CHILD OF ITS CLIENT - which the end state cannot use, because there
// the server is a standing application and the client is elsewhere, possibly on
// another kernel. So fork/execve survives here only as A WAY TO START A
// PROCESS, which is all it ever needed to be, and every fd it used to carry is
// gone.
//
// ANTI-RECURSION IS STILL TWO INDEPENDENT CATCHES, and CONTRACT-P6 §3.1 is why
// neither is the original ARCHITECTURE.md §15.1 rule. That rule says the child hard-sets
// Transport to Monolith; a6 measured that 226 live lines test
// `Transport != Monolith` to select the SERVER arm, 148 of them in MG_Backend,
// so obeying it flips the whole backend to frontend glue in the one process
// with no frontend. Instead:
// (a) STRUCTURAL: MOBILEGL_IPC_DIAL=no, a separate axis from which role the
// process plays;
// (b) ENVIRONMENTAL: MOBILEGL_TRANSPORT and every MOBILEGL_IPC_* removed
// from the child's envp.
// The child refuses with exit 64 for (a) and 65 for (b), so the two safeties
// are distinguishable - two that cannot be told apart are one.
// Launches a server process. The child gets an endpoint name and nothing else (no inherited
// descriptors, no shared memory) and listens on it like a server started by hand or by an Android
// Service, so a spawned server never depends on being its client's child.
// Anti-recursion is two independent catches (ARCHITECTURE.md §15.1), not forcing Transport to
// Monolith, which would turn off the server arms selected by `Transport != Monolith`:
// (a) MOBILEGL_IPC_DIAL=no, separate from the process's role; (b) MOBILEGL_TRANSPORT and the
// MOBILEGL_IPC_* knobs scrubbed from the child's environment. The child exits 64 for (a) and 65
// for (b), so the two are distinguishable.
#pragma once
+8 -24
View File
@@ -6,30 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P14 S2 (docs/Disaggregated/design/11-state-ownership.md): ONE SERVED SESSION'S PROCESS-LOCAL
// STATE, and the routing that lets the rest of the tree keep spelling the singletons.
//
// WHAT THE SINGLETONS WERE. ServerSessionInstance()/ServerLoopInstance() were the session, so one
// session per process was a property of every file that named them. This slice gives each served
// session its own {ServerSession, ServerLoop, PipeInputs} triple, held here, and resolves the two
// singleton accessors through the CALLING THREAD's session - which is what makes the ~900 sites
// that spell gPipeInputs, gPipeInputs, ServerSession::Active() or ServerLoopInstance() find the right
// one without one line of any of them moving.
//
// WHO IS A SESSION THREAD. Two threads per session, both created by the session itself:
// * the CONTROL thread - ServerMain::RunSession's own thread, which reads the Hello, drives the
// blocking control pump and ends the session (a std::thread per session in the supervisors, a
// forked process in the `--serve` worker shape);
// * the APPLY thread - ServerLoop's mgl-srv-apply, which applies the ring and owns the backend.
// ThreadSessionScope is installed on both, first thing. A thread with no scope resolves to the
// process-wide defaults, which is exactly the old behaviour and is what the CLIENT process, the
// inproc one-process client+server shape and every unit case keep getting.
//
// THE INPROC CLIENT+SERVER SHAPE IS DELIBERATELY UNTOUCHED. ClientSession::Start calls
// ServerLoopInstance().Start(ServerSessionInstance()) and never opens a scope, so its applier and
// its app thread still share the one process-wide PipeInputs block - the verb barrier's premise
// (one block two roles alternate on) is a property of that shape and this file must not break it.
// Only ServerMain::RunSession, which serves a session with its own transport, opens a scope.
// One served session's process-local state ({ServerSession, ServerLoop, PipeInputs}) and the
// per-thread routing behind the singletons: on a thread inside a ThreadSessionScope,
// ServerSessionInstance(), ServerLoopInstance() and gPipeInputs resolve to that thread's session;
// elsewhere they resolve to the process-wide defaults. ServerMain::RunSession opens the scope on
// its control thread and ServerLoop on the session's apply thread. The in-process client+server
// shape (ClientSession::Start) opens none, so its app thread and applier keep sharing the one
// PipeInputs block the verb barrier relies on; keep it that way.
// See docs/Disaggregated/design/11-state-ownership.md.
#pragma once
#include <Includes.h>
@@ -6,34 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P5f, package fc: the EGL/surface control plane, framed.
//
// WHAT THIS REPLACES. The twelve Server* forwarders used to cross to the apply thread through a
// one-slot mailbox carrying a raw FUNCTION POINTER plus a void* to a stack-local Args struct
// (P5c audit row G4). Both halves are meaningless across a process boundary, which is the whole
// reason P5f exists. The frame below is the seam f0-egl's census prescribed: every forwarder's
// arguments converge into one pure-value struct; inproc the struct crosses the (retained,
// blocking, one-slot) channel by value; under spawn the SAME struct is what SurfaceOpCodec
// encodes into a Wire::SurfaceOp. The thread model does not move - only the payload's shape.
//
// THE FRAME CARRIES NO POINTERS, and that is enforced by the exhaustive member check below:
// adding a member requires updating its structured binding; pointer types fail the assertion.
// The three places the old Args structs held client addresses map onto values:
//
// ServerInitializeEGLDisplay's major/minor out-pointers -> the reply fields eglMajor/eglMinor;
// ServerCreateEGLWindowSurface / ServerSetWindowHandle's const WindowHandle* -> windowBackend +
// nativeToken + width/height (WindowHandle::Handle as an integer);
// every EGL handle (display/surface/context) -> a Uint64 token. Inproc the token IS the handle
// value bit-cast (same process, same driver, and the N-3 tuple bookkeeping compares them);
// under spawn the client mints dense tokens instead (P6-CONTRACT-DRAFT table 0) - the field
// width is already the token's, so nothing here changes shape on that day.
//
// PROCESS-LOCAL WINDOW OBJECTS ARE REFUSED BY NAME on the wire: ANativeWindow* and
// CAMetalLayer* mean nothing in the server process. SurfaceOpCodec names their refusals
// AndroidNativeWindow:use-MOBILEGL_IPC_SURFACE=server and MetalLayer:use-MOBILEGL_IPC_SURFACE=server,
// and they are permanent (Rule H): a client's window never crosses, a windowed client uses
// MOBILEGL_IPC_SURFACE=server, which gives the SERVER a window of its own (kServerOwnedWindowBackend
// below, WindowKind::ServerOwned on the wire).
// The EGL/surface control plane as a value frame. Every Server* forwarder packs its arguments
// into one SurfaceControlFrame: inproc it crosses the blocking one-slot channel to the apply
// thread by value, and under spawn SurfaceOpCodec encodes the same struct as a Wire::SurfaceOp.
// The frame holds no pointers (the exhaustive member check below enforces it): out-pointers
// become reply fields, a WindowHandle becomes windowBackend + nativeToken + size, and EGL
// handles travel as Uint64 tokens (inproc, the handle value itself). Process-local windows
// (ANativeWindow*, CAMetalLayer*) never cross: SurfaceOpCodec refuses them by name, and a
// windowed client uses MOBILEGL_IPC_SURFACE=server (kServerOwnedWindowBackend).
#pragma once
#include <Includes.h>
+8 -18
View File
@@ -6,24 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P11 B2 (docs/archive/contracts/CONTRACT-P11.md B2): T0, the zero-copy adoption of a persistently mapped
// store. The CLIENT allocates the store as an AHardwareBuffer (AHB), keeps a CPU lock on it for
// the store's life and uses that pointer as the adopted mapping; the SERVER imports the same
// pages as the store's resident twin (Espryt glBufferStorageExternalEXT, Magma
// VK_ANDROID_external_memory_android_hardware_buffer).
//
// HOW THE HANDLE CROSSES, AND WHY IT IS ORDERED WITH THE RECORDS THAT USE THE STORE. The adoption
// rides the data-ring map_persistent record (not a control op, which the apply thread could
// overtake under run-ahead). Its AHB travels over the session's aux socket as ONE descriptor: a
// fresh socketpair end (the "hop") on which AHardwareBuffer_sendHandleToUnixSocket already wrote
// the handle, with an Offer in the sideband naming the record's seq. The hop keeps the aux channel
// uniform (one fd + sideband per message, FdPassing's shape on every route) and the AHB's own
// message format off it. Step 1 of B2 proved both this and the direct form on the Redmi across
// every route (shell, another app, same app, spawned child; 64 KiB to 128 MiB).
//
// Every T0 map_persistent is preceded by exactly one Offer for its seq - with the handle, or with
// flags 0 when this client could not allocate one - so the server always has something to wait
// for and never waits for nothing.
// T0: zero-copy adoption of a persistently mapped buffer store (ARCHITECTURE.md §12). The client
// allocates the store as an AHardwareBuffer and keeps it CPU-locked for the store's life as its
// mapping; the server imports the same pages as the store's resident copy. The handle travels
// with the data-ring map_persistent record, not as a control op, which the apply thread could
// overtake under run-ahead: one aux-socket descriptor (a socketpair end the handle was written
// on) plus an Offer naming the record's seq. Every T0 map_persistent is preceded by exactly one
// Offer for its seq, with the handle or with flags 0 when the client could not allocate one, so
// the server never waits for an Offer that is not coming.
#pragma once
+7 -27
View File
@@ -6,33 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P11 A1 + B2 (docs/archive/contracts/CONTRACT-P11.md §1 and B2): THE PERSISTENT-MAP ADOPTION TIER IS ASKED
// AT THE HANDSHAKE, BY THE SIDE THAT KNOWS THE DATA PLANE, BEFORE THE FIRST map_persistent.
//
// Tier names follow docs/Disaggregated/design/07: T0 = the client allocates an AHardwareBuffer
// and the server imports it; T1 = the server exports an opaque fd (CLOSED, ID-P11-1); T2 = the
// resource owner declines and the client keeps the shadow and pushes.
//
// MOBILEGL_IPC_ADOPT_TIER IS THE SWITCH (ruling ID-P11-14). UNSET - the default since 2026-09-29,
// the user's decision - asks for T0 and falls back to T2 QUIETLY (MGLOG_D only, still counted),
// so a tcp session or a host session is not one line longer; 0 asks for T0 BY NAME (the table
// below); 1 is refused by name and runs T2; 2 is T2 byte for byte. T0 that this session cannot use
// is never a Fatal - it is T2 plus one named line (knob 0) or one D line (unset):
//
// data plane side knob 0 knob 1
// Stream (tcp) either `Refuse{AdoptTierOnStream, "T0"}`, T2 `Refuse{AdoptTierOnStream, "T1"}`, T2
// (A1, unchanged: a stream shares no memory, so no tier but T2 exists)
// SharedSegments client asks T0 in its Hello (LinkTerms. `Refuse{AdoptTierClosed, "T1"}`, asks T2
// adoptTier = 0); whether it runs T0 is
// read at the first map_persistent from
// kCapAdoptT0 (ClientSession::AdoptT0)
// SharedSegments server its own knob is not consulted: the client's ask decides, and the
// server grants it at its first native bind (ServerSession::
// SettleAdoptT0AtBind) or names why not
//
// In MG_Remote/Transport (the link-ratchet's SERVER partition) because both roles call it:
// ServerSession::Accept and ClientSession's two Start halves. The at-use check the server's
// applier and codec ask on the T2 path (Client::AdoptTierIsEmulate) reads the settlement back.
// The persistent-map adoption tier is settled at the handshake, before the first map_persistent,
// by the side that knows the data plane. T0: the client allocates an AHardwareBuffer and the
// server imports it; T1: a server-exported fd (closed); T2: decline, the client keeps the shadow
// and pushes. A T0 the session cannot use is never Fatal: it is T2 plus one line. The rules per
// data plane and knob value are in ARCHITECTURE.md §12.1. Lives in Transport because both roles
// call it (ServerSession::Accept, ClientSession::Start); Client::AdoptTierIsEmulate reads the
// settlement back on the T2 path.
#pragma once
+6 -30
View File
@@ -6,36 +6,12 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// PH-7 (1)(2)(3), ID-P7-3. THE ONE PLACE THAT KNOWS WHAT `MOBILEGL_IPC_TOKEN` MEANS.
//
// It was three places, and the three did not agree:
//
// SocketTransport.cpp's ListenTcp asked only "is it non-empty", and a one-byte token
// therefore unlocked a `tcp://0.0.0.0` listen.
// ServerSession.cpp compared with `std::strcmp` under "the role is not InProcess AND a token
// is configured".
// ServerMain.cpp compared with `std::string::operator==` under "the control connection is
// TCP", which additionally demanded that a peer present an EMPTY token to a server that had
// configured none - a rule the other site did not have and no document states.
//
// Two of the three compared byte-at-a-time and returned at the first differing byte. That is the
// textbook remote timing oracle: an attacker who can retry learns the token one byte at a time
// instead of guessing 2^128. Whether it is exploitable over a LAN at these timings is beside the
// point; the comparison costs the same either way, and a security property nobody has to
// re-derive is worth more than a measurement.
//
// WHY A MINIMUM LENGTH IS A LISTEN-TIME REFUSAL AND NOT A WARNING. Ph's threat model (ID-P7-3)
// is "anybody who can reach the port has a remote code path". The answer to it is the token, so
// a token an attacker can enumerate is not an answer - it is the appearance of one, which is
// worse than no token at all, because `no token` is already handled: it confines the listener to
// loopback. A four-byte token, before this file, did not confine anything. 16 bytes is the
// floor, it is checked where the listener is opened, and a short token FAILS THE LISTEN rather
// than silently degrading to loopback-only: an operator who set a token and got a loopback
// server would have no way to tell that from success, which is the exact silence Ph exists to
// remove.
//
// The token is never logged, at any level, on any path. The refusals name the policy, not the
// secret (CONTRACT-P65: "错令牌返回 Refuse,不打印令牌值").
// The one definition of what MOBILEGL_IPC_TOKEN means; listeners and handshake checks use these
// helpers (ARCHITECTURE.md §15.1). No token confines the listener to loopback. A configured
// token shorter than kMinimumAuthTokenBytes fails the listen rather than falling back to
// loopback, since an operator who set a token could not tell that fallback from success.
// Comparisons are constant time: a byte-at-a-time compare lets a peer that can retry learn the
// token one byte at a time. The token is never logged; refusals name the policy, not the secret.
#pragma once
+38 -162
View File
@@ -6,50 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The bidirectional doorbell: spin briefly, then park.
//
// Both directions exist, and that is the point (inherited design, earlier plan
// section 6.2a):
// - client -> server: the consumer spins, sets consumerParked, then blocks;
// the producer rings only when consumerParked is set.
// - server -> client: the client spins MOBILEGL_IPC_SPIN_US (default 50us),
// sets producerParked, then blocks; the server rings after advancing any
// watermark, only when producerParked is set.
// Without the second direction every client wait - present credit, a blocking
// kNeedsAck request, a full ring - degenerates into a cross-process spin on
// one shared cache line: up to a whole frame of a big core at full clock on a
// phone, fighting the GPU and the game's JVM for it. MobileGL has no affinity
// control anywhere in the tree, so it cannot even be pushed to a little core.
//
// Two implementations, no platform-specific wakeup primitive (no futex, no
// eventfd, no named event):
// - CondVarDoorbell for `inproc` (one process, two threads),
// - SocketDoorbell for `spawn` (one byte on a socket; POSIX only).
//
// The lost-wakeup window is closed by two seq_cst FENCES, not by the ordering
// of the park flag's own load and store:
// - the waiter sets the flag, executes std::atomic_thread_fence(seq_cst),
// and THEN re-tests the condition (Doorbell::Wait);
// - the notifier publishes its watermark, executes the same fence, and THEN
// reads the flag (NotifyIfParked).
// Both fences sit in the single seq_cst total order, so one precedes the
// other, and [atomics.order] then forces at least one side to observe the
// other's store. The flag's own accesses may be relaxed: they are not what
// closes the window.
//
// A seq_cst store paired with a seq_cst load would NOT be enough, which is
// why the fences are here and why neither may be removed. That Dekker
// argument needs all FOUR accesses in the total order, and the other two are
// not: the watermark publish is a release store (RingProducer::Publish) and
// the condition re-test is an acquire load. On x86 the gap is concrete rather
// than theoretical - a release store is a plain MOV that can still sit in the
// store buffer while the load of the park flag, also a plain MOV, reads 0, so
// the notifier skips the ring and the waiter parks on a stale watermark
// forever. (ARMv8 survives it only because STLR->LDAR is RCsc, i.e. by luck.)
//
// The other half of the contract is ordering between the caller and the
// fence: NotifyIfParked must be called AFTER the watermark is published. A
// fence only orders what precedes it.
// The bidirectional doorbell: a waiter spins, sets its park flag, then blocks, and the other side
// rings only when that flag is set (client -> server on consumerParked; server -> client on
// producerParked, after any watermark advance). Without the server -> client direction every client
// wait would be a long cross-process spin on one shared cache line. No platform wakeup primitive
// (futex, eventfd, named event): CondVarDoorbell in process, SocketDoorbell across processes.
// Two seq_cst fences close the lost-wakeup window: Doorbell::Wait's (after setting the flag, before
// re-testing) and NotifyIfParked's (after publishing, before reading the flag). Neither may be
// removed, and NotifyIfParked must follow the publish; why: ARCHITECTURE.md §11.3.1.
#pragma once
@@ -83,45 +47,14 @@ namespace MobileGL::MG_Remote::Transport {
#endif
}
// -----------------------------------------------------------------------
// THE SPIN BUDGET IS COUNTED IN ITERATIONS, NOT IN CLOCK READS.
//
// WHY IT CHANGED (P5d round 3, package T item 3). Wait() used to spin until
// steady_clock passed `start + spinUs`, reading the clock once per 64 spins.
// On the workload device a steady_clock read is a real syscall (no vDSO), and
// simpleperf at head 56a77348 measured the GL thread paying
// steady_clock::now() 2.34 self / 5.91 inclusive plus __kernel_clock_gettime
// 2.37 and clock_gettime 1.94 - about 6.6% of the thread spent asking what
// time it is, at 900+ waits per frame, for a duration that is a HINT. The
// common case (WaitForApplied, server not yet there) paid at least two of
// those reads per wait even when the spin succeeded on its second iteration.
//
// WHAT REPLACES IT. Once per process the loop measures itself: how many
// `load(acquire)` + CpuRelax() iterations fit in a microsecond. Wait() then
// spins `itersPerUs * spinUs` iterations and reads no clock at all; only the
// park phase - which is about to block, so a syscall there is noise - computes
// a deadline.
//
// THE OVERSHOOT BOUND, STATED HONESTLY. The old loop overshot by at most one
// batch of 64 yields. This one overshoots by whatever the calibration was
// wrong by, and the calibration is a single reading taken on one core at one
// frequency:
// - a later DVFS drop or a migration to a little core stretches the spin by
// the frequency ratio (on the workload device's big/little spread that is
// bounded by roughly 3-4x, so a 50 us budget can become ~200 us);
// - a preemption inside the spin adds one scheduler slice, exactly as before;
// - the calibration probe does not run the caller's `ready()`, which is one
// or two acquire loads, so a real iteration is dearer than a probed one and
// the spin ends EARLY rather than late by that factor;
// - the clamps below cap the pathological directions: a probe that was
// preempted cannot produce a budget smaller than kMinSpinItersPerUs, and a
// probe that saw a broken clock cannot produce one larger than
// kMaxSpinItersPerUs.
// None of it is a correctness bound: `ready()` is re-tested every iteration
// and the deadline, when there is one, is still measured against the real
// clock in the park phase. The cost of being wrong is CPU burnt before a park
// (too long) or a park that a few more spins would have avoided (too short).
// -----------------------------------------------------------------------
// The spin budget is counted in iterations, not clock reads: CalibrateSpinItersPerUs measures
// once per process how many acquire-load + CpuRelax() iterations fit in a microsecond, and Wait
// spins itersPerUs * spinUs without reading a clock (a steady_clock read can be a syscall, and
// Wait runs hundreds of times per frame). Only the park phase computes a deadline. The budget
// is a hint, not a correctness bound: `ready()` is re-tested every iteration; a DVFS drop or a
// move to a little core stretches the spin by the frequency ratio (roughly 3-4x); the probe
// omits `ready()`, so real spins end early; the clamps in CalibrateSpinItersPerUs bound bad
// readings.
namespace Detail {
// 0 until the first Wait calibrates it. Plain relaxed atomic rather than a
// function-local static: a static's guard variable is a load and a branch on
@@ -182,29 +115,12 @@ namespace MobileGL::MG_Remote::Transport {
// peer process, which is what makes the whole latch a no-op under inproc and monolith.
virtual bool PeerHungUp() const { return false; }
// How many Wait() calls on this bell exhausted their spin budget and
// really blocked. THE WAIT LEDGER'S RAW READING: the pair (waits, parks)
// is what says whether the spin budget is sized for the workload, and it
// is the one number a wait counter cannot give - a wait that spun and a
// wait that blocked cost three orders of magnitude apart.
//
// ONCE PER Wait, NOT ONCE PER Park: a wait that blocks, wakes on a
// remembered Notify, re-tests false and blocks again is ONE wait that
// paid the park, and a `chunkMs == 0` Park is a poll that does not block
// at all. Both are counted out at the site below, so parks <= waits holds
// for a single waiter.
//
// PER BELL, THOUGH, NOT PER WAIT PATH. A bell belongs to an ENDPOINT and
// every waiter on that endpoint shares it: the client's self bell carries
// SessionProducer's barrier waits AND the encoder's SEG_STAGE retirement
// wait (PipeWireCodec's m_stageRetirementBell, which is the same object).
// A ledger that prints parks as the subset of ITS OWN waits must pass a
// `parkTally` to Wait rather than read this; this counter answers "parks
// on this bell", which is what a test on a private bell wants.
//
// Read from any thread; the counter is monotone and relaxed, so a reader
// may see a value one behind, which is exactly what a per-frame gauge
// wants.
// How many Wait() calls on this bell exhausted their spin budget and really blocked,
// counted once per Wait (re-parks and zero-length polls are not counted), so parks <= waits
// for one waiter. The count is per bell, and a bell is shared by every waiter on its
// endpoint (the client's self bell also carries PipeWireCodec's SEG_STAGE retirement wait);
// a caller that needs parks as a subset of its own waits passes `parkTally` to Wait
// instead. Monotone and relaxed: a reader on another thread may see a value one behind.
std::uint64_t ParkEntries() const { return m_parkEntries.load(std::memory_order_relaxed); }
// Spin `spinUs`, then park until `ready()` or the deadline.
@@ -221,24 +137,9 @@ namespace MobileGL::MG_Remote::Transport {
return true;
}
// THE SPIN PHASE READS NO CLOCK AT ALL. It used to read one per 64
// iterations plus one before the loop - see the SpinItersPerUs block
// above for the measurement that made even that too dear, and for the
// overshoot bound the iteration budget buys instead. The duration is
// still a hint; what changed is that asking for it is free.
//
// spinUs == 0 NOW MEANS ZERO ITERATIONS, AND THAT IS A DELIBERATE CHANGE
// rather than a side effect. The old batched loop tested
// `(++spinBatch & 63) != 0 || now < spinEnd`, so a caller that asked for
// no spin still ran 63 iterations before the first clock read could end
// the loop. That was an artifact of the batch size, never a promise, and
// multiplying a budget the caller deliberately set to zero would be the
// wrong way to keep it. Two callers pass 0: PipeWireCodec's SEG_STAGE
// retirement wait (rare, already slow, and it wants the park) and any
// lane that sets MOBILEGL_IPC_SPIN_US=0 as a no-spin control, which now
// really gets no spin - which is the point of such a control. The
// short-circuit also keeps a zero-spin caller from paying the one-time
// calibration probe for a reading it will never multiply by.
// The spin phase reads no clock (see SpinItersPerUs). spinUs == 0 means no spin at all
// and skips the calibration probe: PipeWireCodec's SEG_STAGE retirement wait passes 0
// because it wants the park, and MOBILEGL_IPC_SPIN_US=0 is a no-spin control.
const std::uint64_t budget =
spinUs == 0 ? 0ull
: static_cast<std::uint64_t>(SpinItersPerUs()) *
@@ -294,27 +195,12 @@ namespace MobileGL::MG_Remote::Transport {
std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now).count();
chunkMs = remaining <= 0 ? 0 : static_cast<std::uint32_t>(remaining);
}
// COUNTED HERE, at the one place a waiter stops spinning and
// really blocks (P5d round 3, package T item 4). Unconditional
// rather than behind the stats latch: it is one relaxed add on a
// path that is about to enter a condition variable or a poll(),
// and a counter that only exists when MOBILEGL_PIPE_STATS is set
// could not be read by a test that wants to prove the spin budget
// avoided the park.
//
// TWO THINGS ARE COUNTED OUT, because this number is published as
// the SUBSET of the waits that blocked, and a subset that can
// exceed its own denominator is worse than no number at all:
// - RE-PARKS. One Wait can traverse this loop many times: a
// CondVarDoorbell remembers a Notify delivered while nobody
// was waiting, so Park returns at once, `ready()` is still
// false (the peer had already drained what it published) and
// the wait parks again. That is one wait that paid a park, not
// two, so only the first is counted.
// - POLLS. `chunkMs == 0` is the last sub-millisecond of a
// finite timeout rounding down; Park(0) does not block, and
// counting it would charge a park per iteration of a tight
// re-test loop that never left the CPU.
// Counted here, where a waiter really blocks, and unconditionally (one relaxed
// add before a condvar wait or poll), so a test can read it without
// MOBILEGL_PIPE_STATS. Only the first blocking Park of a Wait counts: a
// CondVarDoorbell can return at once on a remembered Notify and re-park, and
// chunkMs == 0 is a non-blocking poll at the end of a finite timeout. That keeps
// parks a subset of waits.
if (chunkMs != 0 && !countedPark) {
countedPark = true;
m_parkEntries.fetch_add(1, std::memory_order_relaxed);
@@ -438,23 +324,13 @@ namespace MobileGL::MG_Remote::Transport {
int Fd() const { return m_fd; }
// P6 `dl` (CONTRACT-P6 D5c). THE DESCRIPTOR THAT ANSWERS "IS THE PEER STILL THERE",
// which for this object can never be the one it parks on.
//
// The client parks on clientBell[0] and rings ITSELF through clientBell[1], so it holds
// BOTH ends of that socketpair. EOF arrives only when every writer closes and the client
// is one of them, so that descriptor cannot hang up no matter what happens to the server.
// A bell that can wake itself is a bell that cannot hear a death. Measured: a server that
// died on its first Clear left the client waiting out the full 120 s barrier and
// reporting Fatal{BarrierTimeout} - the wrong diagnosis, two minutes late.
//
// The witness is a descriptor whose FAR END ONLY THE PEER HOLDS - in practice the control
// socket. It is NOT OWNED here and is NEVER READ FROM: Park adds it to the poll set with
// `events` asking for hangup ALONE, so a control reply sitting unread in its queue cannot
// wake the bell and cannot be consumed from under SocketTransport's reassembler. What it
// contributes is exactly one fact, and only once: the peer is gone.
//
// -1 disables it, which is what every bell that has no peer socket uses.
// The death witness: a descriptor whose far end only the peer holds (in practice the
// control socket). The park descriptor cannot report the peer's death when this side also
// holds the notify end of the same socketpair (the client rings itself through
// clientBell[1]), so without a witness a dead server surfaces only as a barrier timeout.
// Not owned and never read: Park polls it for hangup only, so unread control replies
// neither wake the bell nor get taken from SocketTransport's reassembler. A hangup sets
// PeerHungUp(). -1 disables it.
void SetDeathWitness(int fd) { m_witnessFd = fd; }
private:
+7 -24
View File
@@ -6,30 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// SEG_EVENT: the server -> client reverse channel. Owner: package s1.
//
// WHAT P5 OWES HERE AND NOTHING MORE (BRIEF R-12, and s1's brief): it must be
// able to CARRY OnBufferWriteback, OnGpuWritten and OnSurfaceChanged. The other
// seven MGPipeCallbacks members are off P5's reduced path, and THE OVERFLOW
// POLICY IS P9's - what is here is the mechanism (a full ring latches
// eventRingFull, a lossy post that is dropped counts in eventDropped) and not a
// policy that decides between them.
//
// IT IS A SECOND RingControl, NOT A THIRD CURSOR SET. RingControl carries two
// cursor triples (SEG_CMD and SEG_STAGE) and adding a third would resize the
// shared page that Ring.h static_asserts at exactly 4096 bytes. So SEG_EVENT
// gets its OWN control page at its own head and drives it with the Cmd cursor
// set - the same RingProducer/RingConsumer code, in the opposite direction. The
// two EVENT FLAGS still live in the SEG_CMD page, because that is where Ring.h
// declares them and where the client's own waits already look.
//
// THE PAYLOAD SHAPES ARE FIXED-WIDTH AND LIVE HERE, not in MG_Pipe's headers.
// Nothing under Transport/ may reach MobileGL/Includes.h (WireLog.h:9-24, and
// the purity gate's `wire-header` probe), so MGPipeHandle / MGPRange /
// MGPSurfaceInfo cannot be named in this file. The wire shapes below mirror them
// field for field and the SESSIONS assert the two agree, which is the same
// discipline the rest of the wire is held to: a wire struct is fixed-width, and
// the conversion happens where the frontend types are legal.
// SEG_EVENT: the server-to-client reverse channel for MGPipeCallbacks events; the overflow rules
// (eventRingFull, eventDropped) are ARCHITECTURE.md §11.7. It has its own RingControl page,
// driven with the Cmd cursor set by the same producer/consumer code in the opposite direction,
// because a third cursor set would grow RingControl past its fixed 4 KiB page. The two event
// flags stay in the SEG_CMD page, where Ring.h declares them. Nothing under Transport/ may
// include MobileGL/Includes.h, so the payloads below are fixed-width mirrors of MGPipeHandle,
// MGPRange and MGPSurfaceInfo, converted by the sessions where the frontend types are legal.
#pragma once
+6 -18
View File
@@ -6,24 +6,12 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// SCM_RIGHTS descriptor passing over an AF_UNIX socket pair. POSIX only.
//
// This is the FIRST transport commit, deliberately (inherited design, plan
// section 8.1, "SCM_RIGHTS must be implemented in the first transport
// commit"). The earlier branch pushed it to a later phase and hardcoded
// `out->fd = -1` in its offer poll, so on the only platform that matters its
// data plane could never move a byte: every segment announcement resolved to
// "no descriptor". A transport whose shm cannot cross the process boundary is
// not a transport.
//
// Channel shape: a dedicated AF_UNIX SOCK_DGRAM socketpair, NOT the control
// byte stream. Two reasons:
// - SOCK_DGRAM preserves message boundaries on every POSIX (SOCK_SEQPACKET
// does not exist on macOS), so one sendmsg is exactly one recvmsg and the
// ancillary data can never be split away from its payload;
// - ancillary data attached to a byte stream binds to whichever ordinary
// byte happens to be at the front of the reader's buffer, which is
// unmanageable once frames are being reassembled.
// SCM_RIGHTS descriptor passing over an AF_UNIX socket pair (POSIX only); without it the shared
// segments cannot cross the process boundary. The channel is a dedicated SOCK_DGRAM socketpair,
// not the control byte stream: datagrams keep message boundaries on every POSIX (macOS has no
// SOCK_SEQPACKET), so one sendmsg is one recvmsg and ancillary data stays with its payload; on a
// byte stream it would bind to whichever byte is at the front of the reader's buffer, which the
// frame reassembler cannot manage.
#pragma once
+7 -20
View File
@@ -6,26 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// Control-channel wire framing: [u32 magic 'MGLF'][u32 payloadLength][payload].
// Length excludes the 8-byte header and is capped at 64 MiB.
//
// Two defects of the earlier branch's codec are fixed here, and both are the
// reason this file is not a copy of it:
//
// 1. Its Feed() unconditionally returned OK and its header peek merely
// returned false on a bad magic or an oversized length. A corrupt or
// desynchronized stream therefore turned into a silent, permanent hang -
// the reader kept waiting for a message that could never be parsed, with
// no error anywhere. Here a violation latches a failed state, is logged at
// ERROR, and every later call returns MOBILEGL_ERR_PROTOCOL_MISMATCH.
//
// 2. Its receive path failed the call and consumed the message when the
// caller's buffer was too small, wedging the stream. Here
// MOBILEGL_ERR_BUFFER_TOO_SMALL reports the required size and KEEPS the
// message queued.
//
// The reader is a plain byte-stream reassembler: it never assumes a read()
// returned a whole frame.
// Control-channel wire framing: [u32 magic 'MGLF'][u32 payloadLength][payload]. The length
// excludes the 8-byte header and is capped at 64 MiB.
// A bad magic or an oversized length latches the reader failed, is logged at ERROR, and makes
// every later call return MOBILEGL_ERR_PROTOCOL_MISMATCH, so a desynchronized stream fails rather
// than hangs. A buffer too small for the next message gets MOBILEGL_ERR_BUFFER_TOO_SMALL with the
// required size and the message stays queued. The reader is a byte-stream reassembler: it never
// assumes a read() returned a whole frame.
#pragma once
+14 -96
View File
@@ -6,64 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The DATA-plane seam. Declared by package c6, implemented by package lk.
// Authority: CONTRACT-P6.md §8.
//
// WHY THIS EXISTS AND WHY IT IS NOT ITransport. ITransport.h:16-20 says in its
// own header that everything on the hot path bypasses it: it carries the
// handshake, surface ops, resync, aux requests and fatals, and that scope is
// already correct for a socket. Below it there was NO interface of any kind -
// Ring.h, ReplySlot.h and EventRing.h each take a raw `void* base` plus a
// capacity, so "swap the transport" had nothing to swap. This header is that
// missing thing, and ITransport is neither extended nor changed by it.
//
// NOTHING INCLUDES THIS FILE IN c6. That is the whole of c6's behaviour
// guarantee: the seam is declared, StreamLink.h is declared against it (which
// is the adequacy proof - see below), and not one call site moves. Package lk
// implements ShmLink by relocating today's SessionSegments / Ring /
// ReplySlotPool / EventRing behind it, line for line, and only then rewrites
// the call sites.
//
// THE SEAM IS SETUP-TIME, NOT HOT-PATH. No method here is called per record,
// per publish, or inside a spin predicate. The producer takes a LinkArena once
// at attach and reserves through it non-virtually; every wait predicate reads
// LinkProgress through a raw pointer taken once. Three grounds, the second
// decisive (CONTRACT-P6.md §8.2):
//
// 1. Two virtuals per record at the 4000 records/frame SEG_CMD is sized
// against is ~8000 indirect calls/frame, about 0.5% of the measured
// 15.72 ms client frame - affordable, but the only lane that could
// measure it has +/-4% dispersion, EIGHT TIMES the effect. A gate that
// can only ever print "within noise" is ID-124's failure mode one level
// down.
// 2. Doorbell::Wait is a non-virtual template whose spin loop calls ready()
// up to SpinItersPerUs() * spinUs times; at 916 waits/frame with 99.8%
// resolved inside the spin, the predicate runs 1e5-1e6 times per frame
// and reads the watermarks straight off the shared page. That is the
// 31.58%-of-the-GL-thread path. A virtual there is not a rounding
// question.
// 3. The encode path already depends on four inline accessors around its one
// Reserve; virtualising Reserve alone forces either four more virtuals or
// moving R-10's wrap counters behind the seam, and those are deliberately
// encoder-owned.
//
// So lk's gate is an objdump call-count of the encode path, not a wall clock:
// after lk that path has no more out-of-line calls than before. That is
// falsifiable; "within noise" is not.
//
// WHAT THIS HEADER DELIBERATELY DOES NOT DO.
// - It does not define Watermark::Reached or any other predicate that
// SessionRings.h already defines. One definition, one owner.
// - It does not relocate the bodies of RingProducer::Reserve or
// RingConsumer::Pop. Those log (MGLOG_E / MGLOG_D) and use Align8 and
// <cstring>; no header under Transport/ pulls the logger in, and this one
// does not start. They stay out of line and move to ShmLink.cpp in lk.
// - It declares no function it does not define. Every entry point here is
// pure virtual or a POD accessor.
// - It does not regroup RingControl. That regroup is real and necessary
// (CONTRACT-P6.md §8.3) but its rename blast radius is 109 production
// sites, five of them behind a non-Android #if, so it is lk's with a
// published per-file itemisation - not a side effect of declaring a seam.
// The data-plane seam: record reservation and commit, watermarks, replies, events and span
// resolution, implemented by ShmLink and StreamLink (ARCHITECTURE.md §11.9). ITransport carries
// only the control plane and is not part of it.
// Reserve, pop and the wait predicates never dispatch through it: callers cache the ring
// endpoints (CommandsOut/CommandsIn) and the LinkProgress pointer once at attach. The spin
// predicate runs up to 1e5-1e6 times a frame, where a virtual call is a measurable cost.
// Watermark predicates such as Watermark::Reached stay in SessionRings.h.
#pragma once
@@ -94,44 +43,13 @@ namespace MobileGL::MG_Remote::Transport {
};
// ---- the progress block -------------------------------------------------
//
// The watermarks every wait predicate reads. On ShmLink this aliases the
// shared control page and the loads are cross-process acquire loads; on
// StreamLink it is process-local and a reader thread stores into it from
// decoded progress messages.
//
// THE RULES SURVIVE THE MECHANISM VERBATIM, and that is the point of naming
// the block rather than the page: late-never-early (Ring.h's "BATCHING MAY ONLY MAKE A WATERMARK LATE"), `>=`
// never `==` (SessionRings.h's Watermark::Reached), monotone with a
// backwards move Fatal (Ring.cpp's AdvanceMonotonic). A stream link changes
// only HOW a value arrives, never what it means.
//
// WHY THESE FOUR AND NOT THE WHOLE PAGE, AND WHY THE EVENT FLAGS ARE NOT
// HERE. RingControl's watermark cache line HELD, until lk regrouped it, {appliedSeq,
// submittedSeq, retiredSeq, completedFrameSerial, presentAckSerial}, and
// submittedSeq is PRODUCER-written (Ring.h's "THE FIVE WATERMARKS" block,
// "Advanced by the PRODUCER after it publishes") while the other four are consumer-written - so the
// line has MIXED WRITERS as it stands and no single-writer block can be
// aliased over it. lk's regroup moves submittedSeq out to the producer's
// own line, beside cmdHead, which is the other thing the producer writes;
// what is left IS this struct, and it IS a named member of RingControl,
// pinned there by per-field offsetof static_asserts (Ring.h).
//
// THE TWO EVENT FLAGS ARE DELIBERATELY EXCLUDED, and an earlier draft of
// this header had them in and was wrong. eventRingFull has TWO writers:
// the server latches it with store(1) when SEG_EVENT fills
// (EventRing.h:140) and the CLIENT clears it with an exchange(0) on EVERY
// DRAIN (EventRing.h:200). Promoting it here would put a per-drain RMW by
// the client on the same cache line as appliedSeq - which the server
// release-stores every 64 records and the client's spin predicate reads
// 1e5-1e6 times per frame. That is new false sharing on the hottest line
// in the system, and it would land in exactly the numbers lk's gate has to
// hold constant. They stay in the doorbell/generation group, where mixed
// writers already live beside consumerParked/producerParked, and cross the
// seam through EventFlags() instead.
//
// It was free to regroup while both peers are the same binary; a wire break the day a second build exists,
// which is why it is booked now rather than later.
// The watermarks every wait predicate reads: on ShmLink an alias of the shared control page,
// on StreamLink process-local, stored by a reader thread from progress messages. Both keep
// the same rules: a watermark may arrive late, never early (Ring.h), compare with `>=`
// (Watermark::Reached), and a backwards move is Fatal (AdvanceMonotonic). It holds only
// consumer-written fields, so it is one RingControl member on its own line (ARCHITECTURE.md
// §11.2). The event flags stay out: the client clears eventRingFull on every drain, an RMW
// that would share the cache line the client's spin predicate reads for appliedSeq.
struct LinkProgress {
std::atomic<std::uint64_t> appliedSeq; // records applied
std::atomic<std::uint64_t> retiredSeq; // GPU finished
+13 -37
View File
@@ -6,27 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The control-plane transport interface.
//
// It is deliberately dumb: complete messages in, complete messages out, plus
// the one thing shared memory cannot do without help - handing a file
// descriptor to the peer. No session routing, no seq accounting, no
// serialization; those live above, in the protocol layer.
//
// Everything on the hot path bypasses this interface entirely: records go into
// the SEG_CMD ring (Ring.h) and the peer is woken through a Doorbell
// (Doorbell.h). ITransport carries the handshake, surface ops, resync, aux
// requests and fatals - the rare, variable-length, must-evolve traffic that
// plan section 7.1 assigns to FlatBuffers tables.
//
// This header stays dependency-light on purpose (mg_protocol_base.h plus the
// standard library): it is included by both roles and by the eventual
// server-side binary, and nothing about a byte pipe needs the GL frontend's
// umbrella header.
//
// Threading: one instance is not internally synchronized for send; callers
// serialize sends. ReceiveFrame/ReceiveFd may be called from one dedicated
// reader thread concurrently with sends from another.
// The control-plane transport interface: complete messages in, complete messages out, plus
// handing a file descriptor to the peer. No session routing, seq accounting or serialization;
// those live in the protocol layer. Hot-path records bypass it (Ring.h, Doorbell.h); it carries
// the handshake, surface ops, aux requests and fatals (ARCHITECTURE.md §11.4).
// Dependency-light (mg_protocol_base.h and the standard library): both roles include it, and a
// byte pipe needs nothing from the GL frontend's umbrella header.
// Threading: callers serialize sends; one reader thread may call ReceiveFrame/ReceiveFd
// concurrently with sends from another.
#pragma once
@@ -60,23 +47,12 @@ namespace MobileGL::MG_Remote::Transport {
// Receives the next complete message.
//
// MOBILEGL_OK - copied into `buffer`, *outSize is
// the message size, message consumed.
// MOBILEGL_ERR_BUFFER_TOO_SMALL - `buffer` is too small. *outSize is
// the size required and THE MESSAGE
// STAYS QUEUED: call again with a
// buffer of at least that size and it
// is still there.
// MOBILEGL_ERR_TIMEOUT - nothing arrived within timeoutMs
// (0 = non-blocking poll).
// MOBILEGL_OK - copied into `buffer`, *outSize is its size, consumed.
// MOBILEGL_ERR_BUFFER_TOO_SMALL - *outSize is the size required; the message stays
// queued for a call with a large enough buffer.
// MOBILEGL_ERR_TIMEOUT - nothing arrived within timeoutMs (0 = poll).
// MOBILEGL_ERR_TRANSPORT_CLOSED - peer gone, nothing left buffered.
// MOBILEGL_ERR_PROTOCOL_MISMATCH- framing violated; the transport is
// latched failed and never recovers.
//
// The buffer-too-small half of that contract is the whole point of
// having one: the earlier branch's transport failed the call AND
// dropped the message, which wedges the stream permanently the first
// time a message is bigger than the reader's guess.
// MOBILEGL_ERR_PROTOCOL_MISMATCH - framing violated; the transport stays failed.
virtual MobileGLResult ReceiveFrame(MobileGLMutableByteSpan buffer, std::uint64_t* outSize,
std::uint32_t timeoutMs) = 0;
@@ -6,30 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The `inproc` transport: two in-memory message queues and a pair of condvar
// doorbells, one connected endpoint at each end.
//
// It is not a test double. `inproc` is a delivery mode of its own - the server
// side is the monolith's own render thread, which is the single largest CPU
// lever this project has, and it is also the CI form of the split build. What
// it does NOT exercise is serialization of the byte stream, so the framing
// codec is covered separately by FramingTest.
//
// It is built by MOBILEGL_BUILD_DISAGGREGATED, the one option this skeleton
// adds, and selected at RUNTIME (plan appendix B: MOBILEGL_TRANSPORT =
// monolith / inproc / spawn / ...). The plan also reserves a separate
// MOBILEGL_BUILD_DISAGGREGATED_INPROC option for the role-isolation shim that
// a single-process CI build will need; that option does not exist yet, and
// nothing here depends on it.
//
// Messages are queued whole, so no framing bytes are involved; the size cap is
// still enforced so that a payload which would be illegal on a socket is
// illegal here too and does not pass CI only to fail after the switch to
// `spawn`.
//
// Descriptor passing is a plain dup(): both ends are the same process, so
// there is nothing to transfer, but the API stays identical so callers can be
// written once.
// The `inproc` transport: two in-memory message queues and a pair of condvar doorbells, one
// connected endpoint at each end. Not a test double: inproc is a delivery mode of its own and
// the CI form of the split build. It carries only the control plane; records use the same
// shared-segment rings as spawn. Messages are queued whole, so framing is not exercised here
// (FramingTest covers it), but the size cap is enforced so a payload illegal on a socket fails
// here too. Descriptor passing is a plain dup(), since both ends are one process, behind the
// same API as the socket transport. Selected at run time by MOBILEGL_TRANSPORT=inproc.
#pragma once
+7 -59
View File
@@ -6,65 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P9 W3. THE SERVER'S LOG LINES ON THE CONTROL PLANE, GRADED, AND NEVER A REASON TO STOP APPLYING.
//
// On TCP the server forwards every line it logs to its client as a LogLine control frame, and the
// client files it under `<base>.server.log` (ControlInbox.cpp). Until this file the forward was a
// blocking SocketTransport::SendFrame made by the LOGGING THREAD, inside the logger: a client that
// stopped reading its control connection - a GL thread parked in a debugger, a ControlInbox whose
// bounded queue filled, a peer that simply stopped - filled the socket, and the next MGLOG on the
// apply thread never returned. Every record behind it waited; a forfeit that would have ended the
// session by name (ServerSession::ForfeitReverseChannel) was itself a log line and never finished
// being said. Design/04 §8.1 had written the policy down from the start (<= WARN lossy, >= ERROR
// lossless and rate limited), and LogLine.level has been on the wire for it since P6; nothing
// used either, and every line went out as Info.
//
// THE SHAPE. A LogForwardChannel owns a queue and ONE sender thread; the forwarder the logger
// calls (Offer) only enqueues, so the logging thread never touches the socket. The sender is the
// only thread that writes log frames, and it writes them in the order they were offered.
//
// THE POLICY, per severity (MOBILEGL_LOG_LEVEL_*):
// * DEBUG / INFO / WARN are LOSSY. A line that would take the queue's lossy bytes past
// LogForwardLimits::lossyQueueBytes is dropped and COUNTED. The drop is not silent to the
// client either: when the gap closes - the next line at or below WARN that fits, a LogFlush
// ack, or Close - the channel puts a WARN line of its own in the stream, `LogForward{Dropped} -
// N line(s) at or below WARN (B bytes) were not forwarded, from <time> until here`, so the gap
// in `<base>.server.log` is marked and counted. (ERROR lines that got through in the middle of
// the gap sit before that line; the time says where the gap began.)
// * ERROR is LOSSLESS, and PACED rather than dropped: at most errorLinesPerSecond leave per
// second after a burst of errorBurstLines, and a line over that budget WAITS in the queue, in
// order (it is delayed, never discarded). Memory still has to be bounded, so the error bytes
// queued are capped too (errorQueueBytes, eight times the lossy cap): a line past it - a peer
// that has not read for that long, or a flood well past the pace for that long - is not
// queued, and is replaced in the stream by one ERROR line `LogForward{Coalesced} - N ERROR
// line(s) were not forwarded here` (queued before the next item that does get in), never by
// nothing. The server's own log file (written before any forward, Log.cpp) has every line
// either way.
// * FATAL is ERROR plus a wait: the logging thread is about to abort, and a line still in the
// queue when it does is a line the client never gets - the one it most needs. So a FATAL
// Offer waits until the sender has written everything up to and including it, for at most
// fatalFlushMs, and then returns whether or not the peer took it - and once such a wait has run
// out, later FATAL lines do not wait again until the sender has written something (MGLOG_F also
// marks survivable conditions, several in a row). (Log.cpp hands a FATAL line over outside the
// log mutex, so that wait holds nobody else's line.)
// * A LogFlush acknowledgement (OfferControlFrame) is queued BEHIND every line offered before
// it, which is what makes MGPipeSyncPeerLog's promise ("every line before the flush is in the
// file when the ack arrives") still true now that a line is not on the wire when MGLOG returns.
// While one is queued the sender does not pace: the client is waiting for it.
//
// THE BOUND. An Offer below FATAL takes the channel's mutex, copies the line and returns; the
// sender never holds that mutex across a socket write. So the time a logging thread spends
// forwarding is a lock and a copy, whatever the peer does - LogForwardCounters::maxOfferNs is that
// time measured, and the F2 fault injection (EventForfeitPeerTest) asserts on it.
//
// CLOSE. Close() stops taking lines, gives the queue closeDrainMs to go out UNPACED, and then stops
// the sender. A sender still inside a write when that budget runs out is blocked on a peer that is
// not reading; Close() then calls the `unblock` hook the owner supplied (ServerMain: shutdown(2) of
// the control socket - the session is over by then) so the thread can be joined rather than
// leaked. What was still queued is counted as abandoned.
//
// SCOPE. Only the TCP server shapes forward (ServerMain.cpp, MOBILEGL_IPC_LOG_FORWARD): a unix spawn
// server and an inproc apply thread write `<base>.server.log` themselves.
// Forwards the server's log lines to its client as LogLine control frames, graded by severity,
// so that logging below FATAL never waits on the peer: Offer() copies the line into a queue, and
// one sender thread writes frames in offer order without holding the queue's mutex. DEBUG to
// WARN are lossy, ERROR is paced but not dropped (coalesced only past errorQueueBytes), FATAL
// also waits up to fatalFlushMs, and a LogFlush ack follows every line offered before it.
// Policy, counters and Close(): ARCHITECTURE.md §8.3.1. Only TCP server shapes forward; other
// servers write `<base>.server.log` themselves.
#pragma once
+13 -65
View File
@@ -6,47 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// SEG_REPLY: the slot pool the server writes a kReplySlot answer into, and the
// client reads it back out of. Owner: package s1.
//
// THE ID IS THE RECORD SEQUENCE NUMBER (P5 R-3). There is no second id space and
// no allocator: the ten kReplySlot calls carry no MGPReplySlot in their payloads
// (that is why the id has to be DERIVED rather than carried), the wire has no
// per-record seq field (ARCHITECTURE.md §4), so the record's ordinal IS its
// reply-slot id. The slot is addressed `seq % slotCount` and the server STAMPS
// THE SEQ BACK INTO THE SLOT HEADER, which is what makes a wrong-slot read
// detectable rather than merely plausible. Seq is 1-based; 0 means "no record".
//
// THE SLOT HEADER IS CONTRACT-P5 TABLE 0's ROW, verbatim:
// { Uint64 Seq; Int32 Status; Uint32 Size; } // 16 bytes, then the payload
// Status: 0 = OK, 1 = DECLINED, 2 = ERROR
//
// DECLINED IS A REAL ANSWER, NOT A FAILURE. It is how MapPersistent says nullptr
// (R-6) and how the four Bool acceptance entry points - ResourceCreate,
// ResourceRespecify, ResourceSubData, SetTextureParams - say false (R-5). A
// client that folds DECLINED into "the call failed" re-creates ID-39's 66 lost
// uploads from the other side, and a client that folds it into OK accepts a
// pointer the server never handed out.
//
// A REPLY LARGER THAN ONE SLOT IS FATAL, NOT CHUNKED, AND THE CLIENT SAYS SO
// FIRST (ID-47). P5's only large answer is ReadPixels, and the client knows its
// size before it emits the record, so it refuses an oversize ANSWER BY NAME
// before emission (RequireReadPixelsFits: Fatal{ReplyTooLarge, "ReadPixels
// <w>x<h> <format> <bytes> > <cap>"}); Post's own refusal is the server's last
// line of defence, and reaching it means the two sides disagree about the frame
// rather than that the pool is too small. The pool still has no knob and a reply
// is still never chunked: since P7 gate 5 (g5-readback) the client splits a READ
// larger than one slot into bands, each an ordinary read_pixels record whose
// answer fits (EmitTables.h PlanReadbackBands), so the refusal is left for the
// one read no banding can answer - a single pixel larger than a slot.
//
// ORDERING. The client only looks at a slot after it has seen
// RingControl::appliedSeq >= its own seq with an ACQUIRE load, and the server
// advances appliedSeq with a RELEASE store AFTER posting the reply
// (PipeApplier::ApplyOne's order: decode -> stamp -> apply -> post -> advance).
// That pair is what publishes the slot's bytes; the fences below are the
// belt-and-braces for a caller - a unit test, or P9's async pool - that reads a
// slot without going through appliedSeq first.
// SEG_REPLY: the slot pool the server writes a kReplySlot answer into and the client reads back.
// The slot id is the record's 1-based seq (0 means "no record"), addressed `seq % slotCount`; the
// server stamps the seq into the 16-byte ReplySlotHeader so a wrong-slot read is detected.
// DECLINED is a real answer, not a failure: MapPersistent's nullptr, the Bool appliers' `false`.
// A reply never exceeds one slot and is never chunked; the client bands large reads
// (ARCHITECTURE.md §13.1.3), so Post's refusal is a last line of defence. The server posts, then
// advances appliedSeq (release); the client reads a slot only after an acquire load shows
// appliedSeq >= seq. The fences in Post and Read cover readers that bypass appliedSeq.
#pragma once
@@ -84,30 +51,11 @@ namespace MobileGL::MG_Remote::Transport {
kReplyStatusError = 2,
};
// Eight slots of a 16 MiB SEG_REPLY is 2 MiB per slot, and MaxReplyBytes() is
// 2 MiB minus the 16-byte header = 2,097,136 bytes per answer (ID-47).
//
// Why eight and not sixty-four: while the verb barrier holds (R-1) the client
// blocks at every verb boundary, so the in-flight depth is exactly ONE and
// every extra slot buys nothing but a smaller maximum answer. The trade is
// the other way round - fewer slots, bigger replies - and P5's only large
// answer is a blocking ReadPixels. P9, which is what makes the pool
// asynchronous, re-chooses this geometry with real depth to size it against.
//
// WHY 2 MiB AND NOT 1. The first version of this file said "1 MiB covers a
// 512x512 RGBA8 read", and it did not: 512*512*4 is exactly 1 MiB and the
// slot header takes 16 of those bytes, so Post refused it by sixteen. Worse,
// the E2 retrace harness's snapshot is a full-surface GL_RGBA/GL_UNSIGNED_BYTE
// read of OpenRA's 640x480 surface = 1,228,800 bytes through the interposer,
// 17% over the old cap - Post would have aborted on the first snapshot the
// day the client's ReadPixels emitter landed. Contract §2 row 23 says the
// slot is "sized from the scenario's largest read rather than guessed";
// 2 MiB is that size for every P5 exit-gate read (E2 is the largest at
// 640x480). A 2400x1080 RGBA8 device surface is ~10.4 MB and is a P6 debt
// (chunked readback or a dedicated readback carrier), recorded in the
// ROADMAP by the integrator - the geometry here is not the place it is paid.
// It was paid at P7 gate 5 by client-side banding (g5-readback), with this
// geometry unchanged.
// Eight slots of a 16 MiB SEG_REPLY: 2 MiB per slot, so MaxReplyBytes() is 2 MiB minus the
// 16-byte header (2,097,136 bytes). Few large slots, because the client never defers reading
// an answer from this pool (ShmLink does not retain replies), so extra slots would only shrink
// the largest answer. 2 MiB holds a full 640x480 RGBA8 read (1,228,800 bytes) in one answer;
// larger reads are split into bands by the client (PlanReadbackBands).
inline constexpr std::uint32_t kDefaultReplySlotCount = 8;
// Slot 0 exists and is used: seq is 1-based, so seq % slotCount hits slot 0
+26 -149
View File
@@ -6,90 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// SEG_CMD / SEG_STAGE ring control and the SPSC producer/consumer over it.
//
// RingControl is the shared page at the head of SEG_CMD, laid out exactly as
// the inherited transport design (plan section 8.1, referring the earlier
// plan's section 6.2) specifies:
//
// - TWO independent cursor triples, one for SEG_CMD and one for SEG_STAGE.
// The stage ring needs its own because "SEG_STAGE has less than a quarter
// left" is a publish trigger and that occupancy cannot be derived from the
// command ring's cursors, and because a stage slot retires on a different
// event than a command record does.
// - THREE separate sequence watermarks. Conflating them is the classic bug:
// appliedSeq releases *AppliedTail, submittedSeq releases staging,
// retiredSeq / completedFrameSerial release *RetiredTail and adopted
// stores.
// - TWO tails per ring, not one. Once the server borrows a ring slot into
// the GPU timeline instead of copying it out again, that slot can only be
// recycled after completedFrameSerial; a single tail would silently
// degrade to conservative reclaim the day borrowing lands.
// - Both park flags, because the doorbell is bidirectional: without the
// server->client direction every client wait degenerates into a
// cross-process spin on one shared cache line (a whole 16.6ms frame of a
// big core, on a phone, competing with the GPU and the game's JVM).
//
// Cursors are monotonically increasing byte counts; the ring is indexed with a
// power-of-two mask. They are never reset, so a torn read can never look like
// a valid earlier position. ringGeneration is bumped after a hard drain to
// invalidate every cached offset.
//
// Record framing inside the ring is the 8-byte header below, which is the
// layout the plan's RecHeader already fixes ({u16 kind, u16 flags, u32 size},
// size including the header and a multiple of 8). The record CATALOGUE
// (Records.def / PipeCalls.def) is a separate deliverable; the ring itself
// only needs kind/flags/size, so it can carry the real records the day they
// land without changing shape.
//
// ---------------------------------------------------------------------------
// THE FIVE WATERMARKS (P5 R-9). Four of them are Transport::LinkProgress and
// are reached as `control.Progress.<name>`; submittedSeq is the fifth and stays
// at RingControl scope, on the producer's line (lk, CONTRACT-P6 §8.3).
// One sentence each, and they are a contract:
// every one of the five was declared here at P0 and written by nobody but
// InitRingControl, so until P5 there was nothing to disagree with.
//
// submittedSeq Advanced by the PRODUCER after it publishes. NOBODY
// WAITS ON IT - it is diagnostic, the answer to "how far
// ahead of the server is the client right now". It lives
// on the producer's own cache line beside cmdHead; it is
// the ONE of the five that is not in LinkProgress, and
// that is why LinkProgress can be single-writer.
// appliedSeq Advanced by the CONSUMER for EVERY SINGLE RECORD it
// applies. The client's verb barrier and every reply wait
// read it, so it is the one watermark P5 FORBIDS BATCHING:
// the sixty-four-record batching this ring was designed
// for makes a waiter block on work that already ran, or -
// far worse - resume on work that has not.
// retiredSeq Advanced by the CONSUMER once it has finished with the
// SEG_STAGE bytes a record referenced. The staging
// allocator reclaims behind it, and nothing else may.
// completedFrameSerial Advanced by the SERVER when a present completes. What
// recycling and ageing wait on; it trails appliedSeq by
// the GPU's own depth and must never be conflated with it.
// presentAckSerial Advanced by the SERVER when it returns a present credit.
// The client's present throttle waits on it; it is the
// only back-pressure that bounds latency rather than bytes.
//
// Every wait on all five is `>=`, never `==`: a waiter that tests equality
// misses the wakeup the moment a producer or consumer moves by more than one.
//
// BATCHING MAY ONLY MAKE A WATERMARK LATE. All five except appliedSeq may be
// published lazily, because a waiter that sees an old value waits longer than
// it had to and is still correct. NONE of them may ever be published EARLY: a
// watermark that reports more than was actually done turns every waiter into a
// silent use of work that has not happened, and there is no checksum anywhere
// on this ring that would catch it.
//
// kRecPad DOES NOT ADVANCE SEQ. A wrap filler is framing, not a record: it has
// no opcode, no payload meaning and no reply slot. Both sides must skip it
// BEFORE counting. If one side counts it and the other does not, the two seq
// spaces drift by one at every wrap - and because seq IS the reply-slot id
// (P5 R-3), a drifted seq silently reads another call's answer rather than
// failing. Nothing on this ring would detect that, which is why the rule is
// stated here rather than left to each side's loop.
// ---------------------------------------------------------------------------
// RingControl, the shared page at the head of SEG_CMD, and the SPSC producer/consumer over it
// (ARCHITECTURE.md §11.2). Cursors are monotonic byte counts indexed with a power-of-two mask and
// never reset, so a torn read cannot look like an earlier position; ringGeneration is bumped after
// a hard drain. Each ring has two tails because a kRecBorrowSlot record retires only after
// completedFrameSerial; both park flags exist because the doorbell rings in both directions.
// The five watermarks (ARCHITECTURE.md §11.2.1, SessionRings.h's Watermark) are waited on with
// `>=` and may be published late, never early; appliedSeq advances once per applied record.
// kRecPad wrap fillers do not advance seq, the reply-slot id: both sides skip them before counting.
#pragma once
@@ -119,28 +43,11 @@ namespace MobileGL::MG_Remote::Transport {
// ---- SEG_STAGE cursors ------------------------------------------------
//
// DEAD IN P5, DELIBERATELY, AND NOBODY MAY WIRE THEM UP HALFWAY.
//
// SEG_STAGE is NOT a ring any more. Package w1's encoder owns staging as
// an ENCODER-LOCAL LINEAR ALLOCATOR: a staged byte run carries no
// RingRecordHeader, there is no consumer walking SEG_STAGE, and the
// allocator reclaims on `retiredSeq` - the sequence watermark below -
// rather than on these three cursors. So all three stay ZERO for the
// whole of P5, `RingCursorSet::Stage` has no producer and no consumer,
// and `SessionTest.TheStageCursorTripleStaysDeadAcrossAWholeSession`
// pins that rather than leaving it to be noticed.
//
// They are kept rather than deleted because RingCursorSet, the three
// cursor accessors in Ring.cpp and RingTest's fixture are all written
// against a two-triple page, and P8/P11's shadow and adopt segments are
// the ring-shaped users this triple was reserved for. What is NOT
// acceptable is the middle state: a producer publishing `stageHead` with
// nothing advancing the two tails makes FreeBytes() fall to zero the
// first time the head laps the capacity and never recover, which is a
// guaranteed hang rather than a slow path. Five watermarks already spent
// a whole phase declared-and-written-by-nobody; this is the sixth, and
// it is declared-and-written-by-nobody ON PURPOSE, which is only
// different if it is written down.
// Unused: staging is the encoder's linear allocator, which reclaims on retiredSeq, so these
// stay zero and RingCursorSet::Stage has no producer or consumer (pinned by
// SessionTest.TheStageCursorTripleStaysDeadAcrossAWholeSession). They keep the page layout
// for a future ring-shaped segment. Never publish stageHead without a consumer advancing
// both tails: FreeBytes() would reach zero after the first lap and the producer would hang.
alignas(64) std::atomic<std::uint64_t> stageHead;
alignas(64) std::atomic<std::uint64_t> stageAppliedTail;
std::atomic<std::uint64_t> stageRetiredTail;
@@ -264,33 +171,12 @@ namespace MobileGL::MG_Remote::Transport {
};
static_assert(sizeof(RingRecordHeader) == 8, "RecHeader is 8 bytes on the wire");
// THESE ARE RING FLAGS AND THEY ARE NOT MGPipeCallFlags, AND THREE OF THE
// BITS COLLIDE WITH A DIFFERENT MEANING. `MGPipeCallFlags` (MG_Pipe/MGPipe.h:
// 42-54) is a SEPARATE SPACE that happens to overlap this one, and an encoder
// that copies `MGPipeCallFlagsFor(op)` into RingRecordHeader::flags without
// translating puts a call's bits into a framing field:
//
// bit 0 kNeedsAck == kRecNeedsAck same meaning, harmless
// bit 1 kHasBlob == kRecHasBlob same meaning, harmless
// bit 2 kVarTail == kRecPad WORST: a var-tail record would read
// as a WRAP FILLER and be skipped
// silently by Pop, losing the record
// with nothing logged anywhere
// bit 3 kHostSpan == kRecBorrowSlot a host-span record would read as
// borrowed into the GPU timeline, and
// the consumer would stop reclaiming
// ring bytes behind it for ever
// bit 4 kReplySlot == kRecVarTail a blocking call would read as having
// a tail it does not have
// bit 5 kOptional == (unused here)
//
// Translating is the ENCODER's job. Two things on this side make the first
// two of those survivable anyway rather than trusting it: `Pop` requires a
// filler to carry BOTH kRecPad AND kind == kRingPadRecordKind, so a real
// record with bit 2 set is delivered rather than eaten (a call record always
// has a real opcode kind, the catalogue starts at 1); and SessionConsumer
// counts and NAMES every kRecBorrowSlot it sees, because P5 produces no
// borrowed slots at all and the bit arriving means the collision did.
// Ring framing flags. MGPipeCallFlags (MG_Pipe/MGPipe.h) is a separate space in the same bits:
// kVarTail == kRecPad, kHostSpan == kRecBorrowSlot, kReplySlot == kRecVarTail. The encoder
// translates call flags and never copies them (PipeWireCodec.cpp's FlagSpace asserts). As a
// backstop, Pop skips a record as a wrap filler only if it carries kRecPad and
// kind == kRingPadRecordKind, and SessionConsumer names every kRecBorrowSlot it sees, since
// nothing produces borrowed slots.
enum RingRecordFlags : std::uint16_t {
kRecNone = 0,
kRecNeedsAck = 1u << 0,
@@ -376,23 +262,14 @@ namespace MobileGL::MG_Remote::Transport {
// Bytes still writable before the consumer has to catch up.
std::uint64_t FreeBytes() const;
// Reserves room for one record and returns a pointer to its payload,
// or nullptr when the ring is full. The payload is uninitialized;
// alignment padding at its tail is NOT zeroed. Emits a pad record
// automatically when the record would straddle the wrap boundary, so
// every record is contiguous.
// Reserves room for one record and returns a pointer to its payload, or nullptr when the
// ring is full. The payload and its tail padding are uninitialized. A record that would
// straddle the wrap boundary is preceded by a pad record, so every record is contiguous.
//
// A record whose total (header + payload, rounded up to 8) exceeds
// MaxRecordBytes() == Capacity()/2 is refused outright, with an error
// log and however empty the ring is: chunking it is the emitter's job
// (plan section 8.2, the G3 chunking rule). Half is exact, not
// conservative - it is the largest record EVERY head offset can place,
// because a wrap pad costs at most total-8 bytes on top of the record
// and 2*total-8 <= capacity-8 holds exactly up to capacity/2. Above it
// a record is placeable at some offsets and not at others, and a
// producer waiting for FreeBytes() >= total stalls forever on an empty
// ring. So: nullptr with FreeBytes() >= total never means "wait"; it
// can only mean "too big, chunk".
// A record whose total (header + payload, rounded up to 8) exceeds MaxRecordBytes() ==
// Capacity()/2 is refused with an error log however empty the ring is; the emitter chunks
// it. Half is the largest total every head offset can place (a wrap pad adds at most
// total-8 bytes), so nullptr with FreeBytes() >= total means "too big", never "wait".
void* Reserve(std::uint16_t kind, std::uint16_t flags, std::uint64_t payloadBytes);
// The largest header+payload total Reserve accepts: Capacity()/2. This
+7 -25
View File
@@ -6,31 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// Peak-RSS accounting for the two roles. Owner: package s1; CONSUMER: package t1,
// which puts the numbers in MEASUREMENTS.
//
// WHY BOTH HALVES ARE NEEDED, AND WHY NEITHER ALONE IS THE ANSWER.
//
// VmHWM is the kernel's own high-water mark of resident set size, in
// /proc/self/status. It is the only number that cannot be argued with - it
// counts what the process actually touched, including the pages the allocator
// never gave back. But under `inproc` BOTH ROLES ARE ONE PROCESS, so a single
// VmHWM cannot be split between them and reporting it as "the client's" would
// be a lie that only becomes visible in P6.
//
// The segment ledger is the other half: every ShmSegment this process mapped,
// by kind and by role. It is exact, it IS separable by role, and under `spawn`
// it is the part that appears in both processes at once (one mapping, two
// address spaces, one set of physical pages) - which is precisely the number a
// naive "sum the two VmHWMs" double-counts.
//
// So the pair is the measurement: VmHWM for what the process really cost, the
// ledger for how much of it is shared mapping that a second process will not pay
// for again. t1 reports both, per role, and the split's memory claim is
// (client VmHWM + server VmHWM - shared ledger), never either half on its own.
//
// A SAMPLE IS A SYSCALL AND A PARSE. Take it at phase boundaries - after the
// handshake, after the first frame, at teardown - never per record.
// Peak-RSS accounting for the two roles, logged per role at phase boundaries. It needs two
// halves: VmHWM (/proc/self/status) is what the process really touched, but under inproc both
// roles are one process and it cannot be split between them; the segment ledger is exact and
// per role, and under spawn its pages are mapped in both processes at once. The split's memory
// cost is client VmHWM + server VmHWM - the shared ledger, never either half alone.
// A sample is a syscall and a parse: take it after the handshake, after the first frame and at
// teardown, never per record.
#pragma once
+26 -112
View File
@@ -6,61 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The ring-owning half of a session: the four segments, the two ring endpoints,
// and the five watermarks. Owner: package s1.
//
// This is the object the P5 gate is really about. ARCHITECTURE.md §10.1 asks that
// `inproc` run THE SAME G3 codec as `spawn`, and InProcessTransport cannot
// deliver that no matter how it is edited: it is two deque<vector<uint8_t>> plus
// two condvar doorbells (InProcessTransport.cpp:38-97), it owns THE BELLS BUT
// NOT THE RING, and it runs no codec at all. So the rings live here, above
// ITransport, in one implementation both delivery modes use - and the transport
// supplies the control plane and the two bells, which is exactly what its own
// header says it is for (ITransport.h:16-20: "everything on the hot path
// bypasses this interface entirely").
//
// INPROC USES ShmSegment TOO, NOT new[]. In one address space a heap allocation
// would work and would be faster to write. It is refused deliberately: it is half
// of what makes "the same code path" true rather than nominal. A `new` here means
// the mapping, the alignment, the size rounding, the read-only peer view and the
// lifetime are all exercised for the first time in P6, on the day the second
// process appears - which is the shape of every "it was green in CI" failure this
// phase is trying not to repeat.
//
// ---------------------------------------------------------------------------
// THE KNOB NAMES THE RING; THE SEGMENT IS THE RING PLUS ONE CONTROL PAGE.
//
// Ring.h:11-13 puts RingControl at the HEAD of SEG_CMD and RingProducer requires
// a POWER-OF-TWO capacity (Ring.cpp:89-103 - the mask IS the indexing). Those two
// facts together mean a segment and its ring cannot both be 8 MiB, and one of the
// two numbers has to give.
//
// The one that gives is the SEGMENT: SEG_CMD is `MOBILEGL_IPC_RING_MB` MiB PLUS
// 4096, so the ring inside it is exactly MOBILEGL_IPC_RING_MB MiB and
// RingProducer::MaxRecordBytes() is exactly half of that. CONTRACT-P5 §5 and
// Config.h's MOBILEGL_IPC_RING_MB comment - "A RECORD MAY BE AT MOST HALF OF
// THIS, so 8 MiB caps one record at 4 MiB" - are then TRUE AS WRITTEN, which
// matters because that sentence is what every other package sizes against.
//
// The first version of this file did the opposite: an 8 MiB segment with a 4 MiB
// ring and a 2 MiB record cap, on the grounds that ProtocolSmokeTest.cpp:72 pinned
// the four announced sizes. That was wrong on the facts - that test builds four
// SegmentRefs from its own literals and round-trips them through the schema; it
// says nothing about what a session announces, and it never mentions
// SessionSegments at all. So the alternative was available at no cost, and the
// version that made two live documents false and left half of SEG_CMD mapped and
// unreachable was the worse of the two.
//
// SegmentRef.sizeBytes therefore announces the MAPPING size (ring + page), which
// is what a spawn peer must mmap. The four numbers a reader recognises - 8 MiB /
// 32 MiB / 16 MiB / 256 KiB (ID-47) - are the RING sizes, which is what the
// knobs name.
//
// SEG_STAGE has no control page of its own: RingControl carries TWO cursor
// triples (Ring.h:101-109) and the stage triple is the second. So SEG_STAGE is
// exactly its ring, and 32 MiB is already a power of two. SEG_REPLY is not a ring
// at all.
// ---------------------------------------------------------------------------
// The ring-owning half of a session: the four segments, the two ring endpoints, the five
// watermarks. The rings live above ITransport (control plane only), so every delivery mode runs
// the same ring and codec code, and `inproc` maps real ShmSegments rather than heap memory so the
// mapping, sizing and peer-view paths are exercised on every run.
// Sizes name the ring: SEG_CMD and SEG_EVENT are a power-of-two ring plus one RingControl page at
// the head, so MOBILEGL_IPC_RING_MB (rounded down to a power of two) is the command ring itself
// and MaxRecordBytes() is half of it; SegmentRef.sizeBytes announces the whole mapping. SEG_STAGE
// (a linear allocator) and SEG_REPLY (a slot pool) have no control page.
#pragma once
@@ -142,24 +95,12 @@ namespace MobileGL::MG_Remote::Transport {
// Private, identically addressed mirrors for the stream data plane.
MobileGLResult CreatePrivate(const SessionSegmentSizes& sizes, MemoryRole role);
// The inproc peer's view of the owner's four segments, booked under the
// OTHER role. It does NOT re-init the control pages - there is one shared
// page per ring and re-initialising it would zero the owner's cursors out
// from under whoever is already using them.
//
// ON POSIX THIS IS A REAL SECOND MAPPING, NOT AN ALIAS: each descriptor is
// dup()ed and adopted through ShmSegment::Adopt + Map, so the peer gets
// its own virtual addresses over the same memfd. That is the same reason
// inproc uses ShmSegment at all (see the header block): the ATTACH half is
// the half P6 replaces with an SCM_RIGHTS Adopt, and aliasing the owner's
// ShmSegment objects would leave it first exercised on the day the second
// process appears - which is exactly the criticism this file levels at
// new[]. It also removes a raw lifetime coupling: an aliased view holds
// pointers into the owner's members with no ownership, so the two Closes
// have to be ordered by hand.
//
// Windows has no Adopt (ShmSegment::Adopt is POSIX-only; the section name
// travels in SegmentRef instead), so there it still aliases and says so.
// The inproc peer's view of the owner's four segments, booked under the other role. It
// does not re-initialise the control pages: they are shared, and that would zero the
// owner's cursors. On POSIX each descriptor is dup()ed and adopted (ShmSegment::Adopt +
// Map), a real second mapping, so the inproc attach runs the same path as the cross-process
// one (AdoptFromDescriptors) and the two views close independently. Windows has no Adopt
// (the section name travels in SegmentRef), so there the view aliases the owner's.
MobileGLResult AttachInProcess(SessionSegments& owner, MemoryRole role);
// P6 `sm`: the spawn client's half. The four descriptors arrived over
@@ -239,26 +180,13 @@ namespace MobileGL::MG_Remote::Transport {
};
// -----------------------------------------------------------------------
// The five watermarks (R-9). Every write and every wait goes through here,
// so the rules in Ring.h's header have exactly one implementation.
// The five watermarks. Every write and every wait goes through here, so the rules in
// Ring.h's header have exactly one implementation.
// -----------------------------------------------------------------------
//
// THE ONE RULE THAT MATTERS: a watermark may be published LATE but NEVER
// EARLY. Late costs a waiter some latency; early makes every waiter a silent
// use of work that has not happened, and there is no checksum anywhere on
// this ring that would catch it. The callers are responsible for never
// calling an advance before the work is done - that is the half only a
// call-site review and R-9's unit cases can enforce.
//
// THE HALF THAT IS MECHANICALLY DETECTABLE - a watermark moving BACKWARDS -
// IS FATAL, not logged-and-ignored. Logging it and returning was the first
// version of this file and it was worse than useless: SessionConsumer keeps
// its own counter, so once the shared appliedSeq is behind, every later
// advance is a no-op for ever and every WaitForApplied(seq, kWaitForever) -
// the verb barrier and every reply wait - blocks permanently. The user sees a
// hang and the only evidence is one ERROR line. This is the same class as
// RingCursorsValid returning false, and Ring.h:181-185 already calls that "a
// Fatal{ProtocolCorruption}, never a retry".
// A watermark may be published late but never early: early lets waiters use work that has not
// happened, and nothing on the ring would catch it, so callers advance only after the work is
// done. A watermark moving backwards is Fatal{ProtocolCorruption}: a stale shared appliedSeq
// would make every later advance a no-op and every WaitForApplied(kWaitForever) hang.
namespace Watermark {
// Producer, after Publish. Nobody waits on it - it is the answer to "how
@@ -379,26 +307,12 @@ namespace MobileGL::MG_Remote::Transport {
Doorbell* SelfDoorbell() const { return m_selfBell; }
std::uint32_t SpinUs() const { return m_spinUs; }
// THE WAIT LEDGER'S CLIENT HALF (P5d round 3, package T item 4), and it is a PAIR
// because one number without the other says nothing. `Waits()` is every entry into
// Park - the R-1 barrier's wait, the present throttle and the ring-space wait, which
// simpleperf at head 56a77348 measured as WaitForApplied 27.9% self on the GL thread;
// `Parks()` is how many of them ran out of spin budget and actually blocked. A high
// wait count with near-zero parks is a thread spinning on a server that is nearly
// there; a high park count is a server that is not. The two readings are what tell
// those apart, and the split's whole frame budget turns on which one it is.
//
// Both counters are OURS, and the park one deliberately is not the bell's own
// ParkEntries(). Doorbell::Wait is the only code that knows whether its spin budget
// ran out, so it does the counting - but a bell belongs to an ENDPOINT, not to a wait
// path, and this one is shared: ClientSession hands the very same object to the
// encoder as its SEG_STAGE retirement bell (ClientSession::Start ->
// PipeWireCodec::SetStageRetirementDoorbell), whose waits never pass through
// SessionProducer::Park and so never reach m_waits. Reading ParkEntries() here would
// fold that second subsystem's parks into a number this header, PipeStats and the
// summary line all describe as the subset of OUR waits - and with the retirement
// wait's spinUs of 0 it parks every time, so the subset could print larger than the
// set it is a subset of. Wait's `parkTally` out-parameter exists for exactly this.
// The client half of the wait ledger. Waits() counts every entry into Park (the verb
// barrier, the present throttle, the ring-space wait); Parks() counts those that ran out of
// spin budget and really blocked. Many waits with few parks means the server nearly keeps
// up; many parks means it does not. Parks is fed through Wait's `parkTally`, not read from
// the self bell's ParkEntries(): that bell also carries the encoder's SEG_STAGE retirement
// wait (spinUs 0, so it always parks), which would let parks exceed waits.
std::uint64_t Waits() const { return m_waits.load(std::memory_order_relaxed); }
std::uint64_t Parks() const { return m_parks.load(std::memory_order_relaxed); }
+8 -19
View File
@@ -6,25 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// One shared-memory segment: SEG_CMD, SEG_STAGE, SEG_REPLY, SEG_EVENT, a
// per-object SEG_SHADOW or a SEG_ADOPT store (inherited segment layout, plan
// section 8.1).
//
// Creation matrix (earlier plan section 6.1):
// - Android: ASharedMemory_create (API 26; libc's memfd_create wrapper
// only appears at API 30, which is above our floor)
// - desktop Linux: syscall(SYS_memfd_create, ...) directly, for the same
// reason - the glibc wrapper is recent and this file has to
// build against old sysroots
// - other POSIX: shm_open + immediate shm_unlink, the fd keeps it alive
// - Windows: CreateFileMappingW in the Local\ namespace
//
// Transfer is NOT done here. On POSIX the fd travels by SCM_RIGHTS
// (FdPassing.h / ITransport::ShareFd) and the name is only a debugging label;
// on Windows the section name travels inside the SegmentRef table.
//
// The Windows implementation is compile-guarded and untested at the time it
// was written: no Windows machine is a correctness gate for this project.
// One shared-memory segment: SEG_CMD, SEG_STAGE, SEG_REPLY, SEG_EVENT, a per-object SEG_SHADOW
// or a SEG_ADOPT store (ARCHITECTURE.md §11.1). Created with ASharedMemory_create on Android
// (libc's memfd_create wrapper needs API 30, above the API 26 floor), syscall(SYS_memfd_create)
// on desktop Linux (old sysroots lack the glibc wrapper), shm_open plus immediate shm_unlink on
// other POSIX, and CreateFileMappingW in Local\ on Windows (compile-guarded, untested: Windows
// is not a correctness gate). Transfer happens elsewhere: on POSIX the fd travels by SCM_RIGHTS
// (FdPassing.h, ITransport::ShareFd) and the name is a debug label; on Windows the section name
// travels in the SegmentRef table.
#pragma once
@@ -233,27 +233,12 @@ namespace MobileGL::MG_Remote::Transport {
} // namespace
namespace {
// A filesystem AF_UNIX path, bounded by sun_path. Refused BY NAME when it
// does not fit rather than silently truncated - a truncated path is a
// different socket, and both sides would then "succeed" onto two
// different names.
// A LEADING '@' MEANS THE ABSTRACT NAMESPACE, which is the convention systemd and D-Bus
// use and the only rendezvous that works on Android.
//
// A filesystem AF_UNIX endpoint needs a writable directory, and an Android app has no
// /tmp - measured, not assumed: the first device run of the spawn arm launched its server
// fine and then died on `could not connect to "/tmp/mgl-15731-...sock" ... No such file or
// directory`, because bind() in the child had nowhere to put the node. The app's own cache
// dir would work, but only if every integrator remembered to say where it is, and the
// library has no way to ask.
//
// An abstract name has no filesystem presence at all: no directory, no mode bits, no stale
// node to unlink after a crash - which is the bind() failure `Listen` currently works
// around. It is scoped to the network namespace, so the two processes of ONE app find each
// other and nothing else can, which is exactly the reach this needs.
//
// Linux-only by construction. A path without '@' still works everywhere, and that is what
// an explicit endpoint (P12's "connect to something somebody else is listening on") uses.
// An AF_UNIX endpoint, bounded by sun_path: a path that does not fit is refused by name,
// never truncated (a truncated path is a different socket). A leading '@' selects the
// abstract namespace (Linux and Android only): no filesystem node, so no writable directory
// is needed (an Android app has no /tmp) and no stale node survives a crash; the name is
// scoped to the network namespace instead of a directory. A path without '@' is a
// filesystem socket and works everywhere.
bool IsAbstractEndpoint(const std::string& path) {
return !path.empty() && path[0] == '@';
}
+13 -65
View File
@@ -6,41 +6,12 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// ITransport over a connected stream socket. Package `so` (CONTRACT-P6.md).
//
// WHY THIS IS THE FIRST P6 CODE THAT RUNS. It is the whole control plane for a
// second process, and it needs NOTHING from the second process to be correct:
// CONTRACT-P6 and the spawn plan both require it green over a plain
// socketpair() with two threads, running the WHOLE InProcessTransportTest
// suite, BEFORE a child is ever forked. Same discipline as SessionRings.h's
// "the attach half is the half P6 replaces, so exercise it now" - a transport
// first exercised on the day the process appears is a transport whose bugs all
// arrive at once.
//
// WHAT IT IS NOT. It is not the data plane. The hot path bypasses ITransport
// entirely (ITransport.h) and crosses through ILink, which is a separate seam
// with a separate implementation. This class carries the handshake, surface
// ops, resync, aux requests and fatals - the rare, variable-length,
// must-evolve traffic.
//
// THREE THINGS IT DOES NOT REINVENT, because they are already in the tree and
// already tested:
// Framing.h - AppendFrame writes [u32 'MGLF'][u32 len][payload]; FrameReader
// is a real byte-stream reassembler with the BUFFER_TOO_SMALL
// keep-the-message semantics ITransport requires. A socket
// hands you arbitrary fragments, which is precisely what
// FrameReader was written for and what InProcessTransport's
// message queue never needed.
// FdPassing.h - SCM_RIGHTS over a DEDICATED SOCK_DGRAM aux socket, so an fd
// offer is one datagram and cannot be half-consumed.
// Doorbell.h - SocketDoorbell already exists, is unit-tested, and has NO
// production construction site until now. It was written for
// this and is priced as new work here, not as already paid for.
//
// LIFETIME. The transport owns its stream fd and its aux fd and closes both in
// Shutdown/dtor. Shutdown is idempotent and tears down BOTH directions, which
// is what ITransport promises and what a socket does anyway: after either end
// calls it, the peer's reads return 0 and its sends fail.
// ITransport over a connected stream socket: the control plane (handshake, surface ops, resync,
// aux requests, fatals). The data plane bypasses ITransport and crosses through ILink.
// Framing.h's FrameReader reassembles the arbitrary fragments a socket delivers; descriptors
// travel by SCM_RIGHTS on a separate aux socket (FdPassing.h), never on the framed stream.
// The transport owns its stream and aux fds and closes both in Shutdown and the destructor.
// Shutdown is idempotent and closes both directions: the peer's reads return 0, its sends fail.
#pragma once
#include <atomic>
@@ -66,36 +37,13 @@ namespace MobileGL::MG_Remote::Transport {
std::unique_ptr<SocketTransport>& outServer);
// ---- independent processes: listen / connect -----------------------
//
// THE TWO PROCESSES ARE NOT COUPLED BY INHERITANCE. An earlier shape of
// this package forked the server and handed it fds 3..6; that can never
// be the end state, where the server is a standing application and the
// client connects to it from somewhere else entirely - possibly from
// another kernel. So the rendezvous is a NAME, and both sides are
// started by whoever starts them.
//
// TWO CONNECTIONS, not one. SCM_RIGHTS needs a socket of its own: an fd
// offer is a sendmsg whose ancillary data rides with specific BYTES, and
// interleaving those bytes with the framed control stream would make the
// frame reassembler and the descriptor receiver race for the same bytes.
// So the client connects twice to the same listening path - control, then
// aux - and each connection's first frame is a PairBind carrying one nonce
// and which half it is. One name, no extra configuration, and FdPassing
// keeps the dedicated socket it was written for.
//
// P11 PAIR: THE SERVER PAIRS THEM BY THAT NONCE, NOT BY ARRIVAL ORDER
// (Server/PairAcceptor.h). "First is control, second is aux" was a protocol
// only while nothing else could reach the listener: a readiness probe's
// lone connect() + close() became the next client's control connection, and
// two clients connecting at once could swap halves (B0-CROSS-APP.md F2).
//
// `path` is a filesystem AF_UNIX path. ARCHITECTURE.md §15.1 ruled
// against one for the FORK shape, where the fds were inherited and a
// path would have been a strictly larger attack surface for no gain.
// Independent processes have to name a rendezvous somehow, and a path
// with 0600 permissions in a private directory is the portable answer;
// the abstract namespace is Linux-only and TCP would be reachable off
// the machine.
// The server is a standing process reached by name; neither side inherits the other's
// fds. A Unix endpoint uses two connections, control then aux, because SCM_RIGHTS
// ancillary data must not interleave with the framed control stream. Each presents a
// PairBind (one shared nonce, which half) as its first frame, and the server pairs them by
// that nonce, never by arrival order (Server/PairAcceptor.h), so a probe's bare connect or
// two clients at once cannot mis-pair. `path` is a filesystem AF_UNIX path (bound 0600),
// '@' plus a Linux abstract name, or tcp://host:port.
static MobileGLResult Listen(const std::string& path, int* outListenFd);
// The client's half: connects twice to `path`, each connection presenting a PairBind
+8 -45
View File
@@ -115,51 +115,14 @@ namespace MobileGL::MG_Remote::Transport {
std::vector<LinkSpan> stage;
std::vector<std::uint8_t> readReply;
std::uint64_t replySeq = 0, sentCmd = 0, receivedCmd = 0, sentEvent = 0, receivedEvent = 0;
// ---- THE RECENT-REPLY RING (P12) -------------------------------------------------
//
// ILink::ReadReply takes a SEQ, and until P12 that was decoration: every caller read the
// answer it had just waited for, which is always the newest one, so a single slot answered
// every question and `replySeq != seq` could stand in for "no such answer". The create
// window is the first caller that reads an OLDER seq - it defers up to four answers and
// takes them in order - and against a single slot every one of those reads fails
// PROTOCOL_MISMATCH for ever. What that looked like on the device, measured: the window
// filled to 4 and STAYED there, `refusals=0` because no answer was ever read, every one of
// the frame's 4,536 creates fell through to the blocking path, and the wall clock did not
// move (29.0 s against the 30.1 s of the same binary with the window off). The deferral
// was pure overhead: four answers held unread, plus one failed drain attempt per record.
//
// ---- AND THE RING WAS NOT ENOUGH, SO THE STORE IS KEYED ON A DECLARATION (P12) -------
//
// THE MEASUREMENT THAT SETTLED IT. With the ring at 8 the drain read back NOTHING:
// `taken=63 pushed=65 readfail=8,872` on a load frame, and the miss probe showed why -
// `want=310` on every miss while the ring had moved on to [1175..1504]. Between two
// creates this client emits on the order of a thousand records and looks at the window
// not once, so a deferral has to survive an unbounded amount of other traffic, and WHAT
// DISPLACES IT IS NOT WHAT THE CLIENT EMITS BUT WHAT THE SERVER ANSWERS - it is behind,
// and its late answers to the EARLIER creates fill a bounded buffer first.
//
// Ring 8 -> 1024 as a one-line experiment, and every deferred answer came back:
// `taken=4,499/4,500 readfail=0`, wall 27.9 s -> 23.0 s, and with the residency budget
// out of the way 28.66 s -> 16.80 s at window 2 and 11.45 s at window 4. So the property
// needed is "the answer is still there when the reader comes back", and 1024 is a
// STAND-IN for it, not the design: 1024 retained answers is ~2 GiB worst case, and "deep
// enough" is a probability rather than a bound.
//
// THE BOUND COMES FROM THE CLIENT. A seq is retained only if the wire DECLARED it will be
// read (ILink::DeclareReplyRead), so the outstanding set is the window's depth plus the
// one blocking row in flight plus whatever a retired window strands - it does not depend
// on how many answers the server chooses to send. Retaining on ARRIVAL instead would grow
// ~11.3 entries per create against a server that ignores the no-reply bit (55,903 answers
// a frame where this client reads 4,501), and any cap would then be reached and would
// evict precisely the oldest WANTED answer - the same wedge, a few hundred creates later.
//
// OVERFLOW IS FATAL AND NEVER AN EVICTION. An evicted wanted answer is unrecoverable:
// sending another create to re-latch the object wipes the server's record for it
// (PipeApply.cpp:2033) along with its PendingUploads (PipeApply.h:332), and the client
// cleared its dirty flags at EMISSION (PipeApply.h:298-304), so nobody re-sends those
// texels. The capacity is sized so that cannot be reached, and reaching it is a bug that
// says its own name.
// ONE DEFINITION, IN THE HEADER, because the client's window is bounded by this number.
// Declared-answer store. ReadReply takes a seq, and the create window reads answers older
// than the newest, after on the order of a thousand other records, so a ring of recent
// answers loses them. An answer is retained only if the wire declared it will be read
// (ILink::DeclareReplyRead), so the client, not the server's answer count, bounds the
// store. Overflow is a Fatal, never an eviction: a lost wanted answer is unrecoverable,
// since a re-sent create wipes the server's pending uploads, which nobody re-sends.
// Measurements: docs/Performance/ipc-create-window.md. The cap is defined in the header
// because the client's window is bounded by it.
static constexpr std::size_t kRetainedCap = StreamLink::kRetainedAnswersMax;
struct StoredReply {
std::uint64_t seq = 0;
+5 -16
View File
@@ -6,22 +6,11 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// A one-function logging shim for the wire layer's header-only code.
//
// MG_Util/Debug/Log.h includes <Includes.h>, the GL frontend's umbrella
// header - 661 headers, measured with `clang++ -H`. That is fine inside a
// .cpp, and Ring.cpp / Doorbell.cpp / the transports all do it. It is not fine
// in a header of this layer: ITransport.h states the rule ("nothing about a
// byte pipe needs the GL frontend's umbrella header") because these headers
// are included by both roles and by the eventual server-side binary, and
// because the disaggregated build's include-graph purity gate (plan section
// 10.3, gate A) asserts on `-H` output rather than on symbols. Framing.h was
// the one header under Transport/ that broke the rule; it now calls this
// instead, and the umbrella stays inside WireLog.cpp.
//
// ERROR and FATAL only, deliberately. Everything routed here is a latched
// protocol violation, never per-frame noise; non-critical wire lines use
// MGLOG_D from a .cpp, where the INFO build compiles them out entirely.
// A logging shim for the wire layer's header-only code. MG_Util/Debug/Log.h includes the GL
// frontend's umbrella header, which headers of this layer must not (ITransport.h's rule; the
// include-graph purity gate checks `clang++ -H` output), so the umbrella stays in WireLog.cpp.
// ERROR and FATAL only: everything routed here is a latched protocol violation, never per-frame
// noise; other wire lines use MGLOG_D from a .cpp, which the INFO build compiles out.
#pragma once
+40 -127
View File
@@ -6,25 +6,14 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P5 package w1: the G3 record codec.
// The record codec: one call in, bytes out; bytes in, one MGPipeApply* call out. This file owns no
// semantics: every decoder arm ends in an existing MGPipeApply* function or in the verb sink.
//
// ONE CALL IN, BYTES OUT; BYTES IN, ONE MGPipeApply* CALL OUT. This file owns NO semantics -
// MG_Pipe/PipeApply.cpp is not edited by this package and every decoder arm ends in an
// existing free function or in the verb sink v1 installs. An arm that "handles" a record
// itself rather than delegating is a review failure (R-4's rule, one level down).
//
// THE THING THAT MOST NEARLY WENT WRONG, WRITTEN AT THE TOP BECAUSE IT IS INVISIBLE:
// MGPWireRecHeader::Flags and Transport::RingRecordHeader::flags ARE THE SAME 16-BIT FIELD,
// and the two flag spaces COLLIDE. MGPipeCallFlags::kVarTail is 1<<2 and
// RingRecordFlags::kRecPad is 1<<2, so an encoder that stamped MGPipeCallFlagsFor(op) into
// the header - which the generated comment ("MGPipeCallFlags of the call") invites - would
// make RingConsumer::Pop skip every one of the nine kVarTail records as a WRAP FILLER.
// Silently, with no checksum anywhere on this ring. MGPipeCallFlags::kHostSpan (1<<3) lands
// on kRecBorrowSlot the same way and would retire those slots on the GPU timeline instead of
// on apply. So: THE HEADER CARRIES RING FRAMING FLAGS, and the call's own flags are read from
// the opcode through MGPipeCallFlagsFor (R-13.4), which is why that table was generated in the
// first place. The static_asserts below pin the collision so it cannot be re-introduced by
// someone who reads the comment and not this file.
// MGPWireRecHeader::Flags and Transport::RingRecordHeader::flags are the same 16-bit field, and the
// call-flag and ring-flag spaces collide (kVarTail == kRecPad, kHostSpan == kRecBorrowSlot,
// kReplySlot == kRecVarTail). The header carries ring framing flags only; a call's own flags come
// from the opcode through MGPipeCallFlagsFor. Stamping call flags would make RingConsumer::Pop skip
// kVarTail records as wrap fillers. The static_asserts below pin the collision.
#include <MG_Remote/Transport/LinkMetrics.h>
#include "PipeWireCodec.h"
@@ -549,25 +538,12 @@ namespace MobileGL::MG_Remote::Wire {
"(R-2.3): a non-zero Size must name a real SEG_*",
WireOpName(op), static_cast<unsigned long long>(blob.Size));
}
// R-2.3's SECOND HALF: "inside SOME segment" IS NOT THE RULE. Contract table 1 gives
// every client->server content blob - groups A, B and C, all nineteen rows - the ONE
// carrier SEG_STAGE, and R-10 sends blobs there whole. Until this arm existed the only
// test was that the run resolved, so `CreateSamplerState.Parameters={Seg=SEG_REPLY,...}`
// was accepted and APPLIED: a server-owned segment, whose reuse is the reply pool's
// business and has nothing to do with stage retirement, carrying bytes the applier
// then read. It also went unpoisoned - NoteResolvedRun skipped every non-stage carrier
// - so rule C's only mechanical control read zero on exactly the record that needed it.
//
// The segment is checked BEFORE the resolve, deliberately: a forged SEG_REPLY run that
// happens to lie inside a mapped reply pool must be refused for naming the wrong
// carrier, not left to pass or fail on whether that pool is mapped at all.
//
// NOT A NEW FATAL FAMILY. The review suggested `Fatal{BlobNotStaged}`; this is
// ProtocolCorruption like every other R-2 honesty arm, because the families are the
// vocabulary the operator and the CI greps share (ProtocolCorruption, AbiMismatch,
// UnmigratedVerb, UnmigratedPipeInput, UnsetCallMask, RingOverrun) and a one-off
// seventh name would be a token nothing else in the tree recognises. The SEGMENT is in
// the message, which is what has to be greppable.
// Every client->server content blob is staged whole in SEG_STAGE, so a blob naming any
// other segment is refused even when its run resolves: a SEG_REPLY run would be read from
// server-owned memory that stage retirement does not govern and the poison audit
// (NoteResolvedRun) does not cover. The segment is checked before Resolve, so a forged run
// is refused for its carrier whether or not that segment is mapped. The family stays
// ProtocolCorruption, shared by every honesty check; the segment number is in the message.
if (blob.Seg != kSegStage) {
return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} seg=%u offset=%llu "
"size=%llu is not SEG_STAGE(%u); every client->server content blob is "
@@ -1167,31 +1143,14 @@ namespace MobileGL::MG_Remote::Wire {
// confirmed one, so the same opcode answers both ways and only the client knows which.
if (noReply) ringFlags |= Transport::kRecNoReply;
// BOTH WRAP READINGS ARE TAKEN FROM THE PRODUCER'S OWN CURSOR, not from a flag Reserve
// does not return. The cursor is a monotonic byte count and the ring is indexed
// `cursor & mask`, so `cursor / capacity` is the number of times the byte area has been
// reused - and Reserve advances the cursor by `total` for a contiguous record and by
// `spaceToEnd + total` when it had to lay a kRecPad filler down to the boundary first
// (Ring.cpp). Everything below is exact arithmetic over those two facts, which keeps
// both counters in the encoder - where R-10's maximum already lives - rather than
// adding members to a Transport class the wire package does not own.
//
// AND THEY ARE TWO COUNTERS BECAUSE THEY ARE TWO EVENTS, which one measurement made
// unmissable: a workload whose records repeat at a uniform stride that DIVIDES the
// capacity lands on the boundary exactly, every time, for ever. Driving 1310824 bytes
// of clears and draws through a 1 MiB SEG_CMD produced ZERO pads - the ring went round
// once and a half and never straddled - so "did a pad happen" is NOT the question "did
// this ring wrap", and a lane that asked the first one while meaning the second would
// have gone red for a property of its own arithmetic.
//
// m_cmdWraps the head crossed a multiple of the capacity: the ring went round.
// Guaranteed once more bytes are written than the ring holds, which
// is what makes it something exit gate E3(e) can ASSERT.
// m_cmdWrapPads a kRecPad filler was laid because a record would have straddled
// the boundary. R-9's "a pad does not advance seq, both sides skip
// it and count again" is about THIS one, and it is RECORDED rather
// than asserted, because whether it ever happens is a property of
// the record sizes and not of the ring.
// Both wrap counters come from the producer's own monotonic head: the ring is indexed
// `head & mask`, and Reserve advances the head by `total`, or by `spaceToEnd + total` when
// it first lays a kRecPad filler up to the boundary (Ring.cpp).
// m_cmdWraps the head crossed a multiple of the capacity; guaranteed once more bytes
// are written than the ring holds.
// m_cmdWrapPads a filler was laid because a record would have straddled the boundary;
// it depends on record sizes (a stride that divides the capacity never
// pads), so it is recorded, never asserted.
const Uint64 headBeforeReserve = m_cmd->LocalHead();
void* slot = m_cmd->Reserve(static_cast<Uint16>(op), ringFlags,
total - sizeof(MGPWireRecHeader));
@@ -1684,22 +1643,12 @@ namespace MobileGL::MG_Remote::Wire {
}
// ---------------------------------------------------------------------------------
// The 71 arms
// The decoder arms
// ---------------------------------------------------------------------------------
//
// EVERY ARM ENDS IN AN EXISTING MGPipeApply* FREE FUNCTION, in the verb sink, or in
// `false`. None of them interprets a field: the packed MGPSubData::Target, the
// MGPImageView::Access encoding and MGPFramebufferState::DrawBuffers' -1 token are all
// read by the applier that already owns them, which is why table 0 could say "a decoder
// that open-codes it is the class-1 defect" and this file could obey.
//
// `false` means "this build deliberately does not implement it" and is the answer for the
// 30-odd rows off P5's reduced path (BRIEF §4's exclusion list: the fence family, the
// query family, compute, XFB, indirect, copy-region, GetTextureImage, GenerateMipmap,
// SetShaderBuffers, SetStreamOutputTargets, ResourceSubDataComplete). They are NOT
// unchecked: every one of them runs the same bounds gate, the same tail cross-check and
// the same blob honesty pass before it declines, so the phase that implements one inherits
// a validated record rather than a validation problem.
// Every arm ends in an existing MGPipeApply* function, in the verb sink, or in `false`. No arm
// interprets a field (the packed MGPSubData::Target, MGPImageView::Access, DrawBuffers' -1
// token): the applier that owns the encoding reads it. `false` declines a row this build does
// not implement, after the same bounds gate, tail cross-check and blob checks as every row.
Bool PipeWireDecoder::ApplyChecked(MGPWireOp op, const void* record, Uint64 size) {
const auto* base = static_cast<const Uint8*>(record);
const void* payload = base + sizeof(MGPWireRecHeader);
@@ -1739,36 +1688,14 @@ namespace MobileGL::MG_Remote::Wire {
const auto tailAt = [&](Uint32 i) -> const Uint8* {
return layout.TailBytes[i] != 0 ? base + layout.TailOffset[i] : nullptr;
};
// The four Bool-returning appliers answer ACCEPTANCE, not "applied" (R-5: the client
// 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 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)". 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.
// P12: MAY THIS RECORD'S ANSWER BE SKIPPED? Read from the RING FRAMING field, not from
// MGPipeCallFlagsFor(op): the client decides per CALL, and the opcode cannot answer for
// it (set_texture_params waits for an unconfirmed object and does not for a confirmed
// one). MGPWireRecHeader and RingRecordHeader are the same eight bytes, so `record` -
// which is the header, with the payload behind it - is where the producer's bit is.
// The four Bool appliers (ResourceCreate, ResourceRespecify, ResourceSubData,
// SetTextureParams) answer acceptance, not "applied", and the client may not re-derive it:
// a discarded, stale-handle or refused record looks the same from the call site. The answer
// goes to the record's reply slot (the rows carry kReplySlot; a waiting client that finds
// none takes Fatal{ReplyMissing}) unless the producer set kRecNoReply in the ring framing
// flags; the client decides that per call (set_texture_params waits only for an
// unconfirmed object), so the opcode cannot. The local tally (LastAcceptance(), the
// counters) is kept either way; decoder tests assert it.
const Bool answerSkipped =
(static_cast<const MGPWireRecHeader*>(record)->Flags &
static_cast<Uint16>(Transport::kRecNoReply)) != 0;
@@ -1860,26 +1787,12 @@ namespace MobileGL::MG_Remote::Wire {
return true;
}
}
// R-6 / R-2.4: A CONSTANT DECLINE IN P5, and the applier is not called at all.
// Two reasons, and the second is the one worth writing down: the answer is a HOST
// POINTER, which cannot cross; and MGPWireRec_MapPersistent's payload is a bare
// MGPHandleOnly, so the record carries NEITHER the `size` NOR the `seedBytes` the
// entry point takes. The phase that lands a real map_persistent needs a payload
// change (MGPipeTypes.h, c0's file) before it can even call the applier.
//
// DECLINED is a real answer, not a failure: the three frontend sites already
// tolerate it (BufferObject.cpp:238, :603-606, :657-660).
//
// THE TIER IS CONSULTED HERE, AND IT IS THE SAME CONJUNCTION THE MONOLITH APPLIER
// USES (PipeApply.cpp's `Transport != Monolith && AdoptTierIsEmulate()`). The arm
// used to decline UNCONDITIONALLY and AdoptTier had no reference anywhere on the
// codec path, so MOBILEGL_IPC_ADOPT_TIER=0 and =1 - which contract §5 promises
// "parse and are Fatal at use, naming P11" - decoded as an ordinary DECLINED and
// the operator got a run that looked like a working T0. Since P11 A1 the tier is
// settled at the handshake (Transport/AdoptTier.h): a stream refused T0/T1 there by
// name and runs T2, shared segments died there, so a settled session answers true
// and only a map_persistent no handshake settled still aborts by name. The return
// value is deliberately not a branch: every surviving tier declines (R-6).
// The T2 path declines without calling the applier: the answer would be a host
// pointer, which cannot cross, and the payload is a bare MGPHandleOnly without the size
// or seed bytes the applier takes. DECLINED is a real answer the frontend tolerates.
// AdoptTierIsEmulate() is asked as in MGPipeApplyMapPersistent, so a T0/T1 knob with no
// handshake-settled tier fails by name instead of passing as a silent T2; its value is
// ignored because every settled tier declines here.
if (MG_Config::Transport != MG_Config::TransportMode::Monolith) {
(void)MG_Remote::Client::AdoptTierIsEmulate();
}
+37 -115
View File
@@ -6,37 +6,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// G3: the MGPipe record codec. Owner: package w1.
//
// This header is the CONTRACT (docs/archive/contracts/CONTRACT-P5.md) in C++ form; P5's c0 package wrote
// it so the other seven could compile on day one against signatures that cannot then move
// under them. Every body below is a named Fatal until w1 lands the real one.
//
// WHAT THIS LAYER IS, AND WHAT IT IS NOT
//
// It turns one MGPipe call into bytes in SEG_CMD (+ SEG_STAGE), and bytes back into ONE CALL
// OF AN EXISTING MGPipeApply* FREE FUNCTION. It owns NO semantics: MG_Pipe/PipeApply.cpp is
// not edited by this package, and a decoder arm that "handles" a record itself rather than
// delegating is a review failure (R-4's rule, one level down).
//
// THE FIVE HONESTY RULES (R-2), because they are what make `inproc` worth running at all.
// In the same address space every shortcut works: MGHostSpan::Ptr dereferences, a blobref
// whose Offset is a host address resolves, and MGPipeApplyMapPersistent's return value is a
// usable pointer. So the codec is held to the SPAWN rules even when it does not need to be:
// 1. encoder writes MGHostSpan::Ptr == nullptr and points Seg/Offset at SEG_STAGE;
// 2. encoder fills a real Seg, a real in-segment Offset and a NON-ZERO Size for every
// MGPBlobRef that carries content;
// 3. decoder Fatal{ProtocolCorruption} on: Ptr != nullptr; a content record with
// Blob.Size == 0; Size != 0 with Seg == kSegNone; Offset + Size past the segment;
// 4. MGPipeApplyMapPersistent returns nullptr under split (R-6; b1's half);
// 5. with MOBILEGL_IPC_AUDIT=1 the server fills a retired record's SEG_STAGE bytes with
// 0xDD, so an implementation that kept a pointer past apply reads 0xDD next frame.
//
// SEQ. The record ordinal IS the sequence number and IS the reply-slot id (R-3): there is no
// per-record seq field on the wire (ARCHITECTURE.md §4) and no second id space. Seq is
// 1-based so that 0 can mean "nothing encoded". A kRecPad wrap filler DOES NOT ADVANCE SEQ -
// both sides must skip it before counting, or every ring wrap offsets the two sides'
// numbering permanently and nothing checksums it (R-9, Ring.h's header).
// The MGPipe record codec: one MGPipe call becomes a record in SEG_CMD (its blob bytes in
// SEG_STAGE), and a decoded record becomes one call of an existing MGPipeApply* function. The
// codec owns no semantics; a decoder arm must delegate, never handle a record itself. It follows
// the cross-process rules even in one address space (ARCHITECTURE.md §4.2).
// Seq is the record ordinal, 1-based (0 = nothing encoded), and also addresses the record's
// reply; there is no seq field on the wire. A kRecPad wrap filler does not advance seq: both
// sides skip it before counting, or their numbering drifts apart at every wrap, undetected.
#pragma once
#include <Includes.h>
@@ -136,26 +112,14 @@ namespace MobileGL::MG_Remote::Wire {
Bool WireProtocolLatch(const char* what, const char* detail);
Bool WireProtocolLatchAt(const char* what, Uint64 got, Uint64 expected);
// R-2.3 arms 1-4 over one record's blobref. Split only; a monolith emission is exempt by
// construction because it never reaches this layer.
//
// ARMS 3 AND 4 ONLY, PLUS ONE THIS FUNCTION HAD TO INVENT. A blobref is honest when it is
// EITHER fully declared - a real Seg, a non-zero Size and an Offset+Size inside that
// segment - OR fully absent, which is all three fields zero. The third shape, a Seg or an
// Offset with Size == 0, is neither, and it is the shape a monolith emitter produces
// today (Seg = None, Offset = a host address, Size = 0), so under split it has to be
// Fatal rather than "absent": a decoder that read it as absent would silently drop the
// bytes of every record an unconverted emitter sent.
//
// ARM 2 - "Blob.Size == 0 on a CONTENT record" - is NOT here and cannot be: whether a
// record carries content is a property of the record's OTHER fields (ChunkMask, the
// destination range, GlobalUboSize, the stage mask), which this signature does not see.
// RequireDeclaredBlob below is arm 2, and the decoder's per-op arm calls it exactly where
// the payload says content is implied.
//
// PH-1 (3): EVERY HONESTY ARM NOW ANSWERS. `true` = honest; `false` = a named fault latched
// (armed session child only - unarmed, the arm dies exactly as before and never returns
// false). A decode-path caller returns on false; an encoder-side caller may discard it.
// Checks one record's blobref (split only; monolith emissions never reach this layer).
// Honest is fully declared (real Seg, non-zero Size, Offset+Size inside the segment) or fully
// absent (all zero). A Seg or Offset with Size == 0 is the monolith emitter's shape and is
// rejected, since reading it as absent would drop the record's bytes (ARCHITECTURE.md §4.2).
// "A content record needs a declared blob" depends on the payload's other fields, so that is
// RequireDeclaredBlob, called by the decoder where the payload implies content.
// Returns true when honest; false when a fault was latched (armed session child only;
// elsewhere the check does not return). Decode-path callers return on false.
Bool CheckBlobIsHonest(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob,
const SegmentTable& segments);
// R-2.3 arm 2: the record's other fields say it carries content, so the blob must be
@@ -185,29 +149,14 @@ namespace MobileGL::MG_Remote::Wire {
const MG_Pipe::MGPDrawRange* ranges,
const MG_Pipe::MGHostSpan& span);
// ---- one record's shape, computed ONCE and read by both sides ----------------------
//
// THE TAIL CROSS-CHECK LIVES HERE AND NOWHERE ELSE (BRIEF §5 w1, contract table 1 group
// B). MGP_WIRE_CHECK_BOUNDS only proves `size >= sizeof(MGPWireRec_X)` - IT CANNOT SEE
// THE TAIL - so a record declaring Count = 4000 while carrying 8 bytes passes it. The
// encoder computes this layout from the payload it is about to write and REFUSES a caller
// whose tails disagree; the decoder computes the same layout from the payload it just
// received and REFUSES a record whose MGPWireRecHeader::Size disagrees. Two readers, one
// arithmetic, so the two sides cannot drift.
//
// EVERY TAIL STARTS 8-BYTE ALIGNED WITHIN THE RECORD, and the encoder zero-fills the gap.
// Eight of the nine kVarTail rows are already aligned by construction (their payload and
// element sizes are multiples of 8); DrawVbo is not - MGPDrawRange is TWELVE bytes, so an
// odd NumDraws leaves the conditional MGHostSpan on a 4-byte boundary, and MGHostSpan
// holds a pointer and two Uint64s. P5 emits no host span at all, so this rule costs
// nothing now and is stated now because the phase that arms kDrawHasUserIndices would
// otherwise have to discover it as a misaligned load on a device.
// THREE TAILS AND NOT TWO SINCE P5e. set_program_bindings (opcode 80,
// docs/archive/contracts/CONTRACT-P5E.md §1) carries the uniform-block bindings, the sampler units and
// the storage-override map as three arrays in three different index spaces, so the array
// that used to be exactly "the two-tail rows plus draw_vbo's conditional second" grew by
// one. Every loop over TailCount below already reads the count rather than the literal 2;
// what changed is the storage and the assembly.
// ---- one record's shape, computed once and read by both sides ----------------------
// MGP_WIRE_CHECK_BOUNDS only proves the payload fits, not the tails: a record declaring
// Count = 4000 with 8 tail bytes passes it. The encoder computes this layout from the payload
// and refuses tails that disagree; the decoder computes it from the received payload and
// refuses a record whose header Size disagrees. Every tail starts 8-byte aligned within the
// record (the encoder zero-fills the gap): MGPDrawRange is 12 bytes, so DrawVbo's second
// tail, an MGHostSpan, could otherwise land on a 4-byte boundary. set_program_bindings uses
// three tails, hence kMaxWireRecordTails.
inline constexpr Uint32 kMaxWireRecordTails = 3;
struct WireRecordLayout {
@@ -294,26 +243,13 @@ namespace MobileGL::MG_Remote::Wire {
// row answers both ways (set_texture_params waits for an unconfirmed object and does not
// for a confirmed one).
// Releases every SEG_STAGE run named by a record the apply side has RETIRED
// (RingControl::retiredSeq, R-9, which this class only ever READS). Called from
// Publish() and whenever StageBytes runs short, so nothing outside this package has to
// remember to; the allocator reclaims behind retiredSeq and nothing else may
// (table 1's "retires" column, R-11).
//
// THE MARK IS HELD ON THIS SIDE, NOT ON THE WIRE. A record does not carry where its
// staged bytes end, so the encoder remembers {seq, stage cursor} per record and
// reclaims to the newest mark whose seq the server has retired. That is exact, needs
// no wire field, and does not depend on the verb barrier - so it keeps working when
// R-1's barrier retires family by family.
//
// SEG_STAGE'S CURSOR TRIPLE IN RingControl IS NOT USED BY EITHER SIDE IN P5, and this
// is the reason. Ring.h makes stageAppliedTail / stageRetiredTail CONSUMER-owned, the
// server never Pops the stage ring (the decoder resolves by offset), and a producer
// that wrote those cursors would be the very shape R-9 forbids one segment over. So
// the allocator below is entirely encoder-local and the triple is left at its
// InitRingControl values. **s1 must not attach a RingConsumer to
// RingCursorSet::Stage**: its m_localTail would never move, and PublishApplied /
// PublishRetired would then walk both cursors BACKWARDS under this allocator.
// Releases every SEG_STAGE run named by a record the apply side has retired
// (RingControl::retiredSeq, only read here). Called from Publish() and when StageBytes
// runs short; nothing else reclaims stage bytes. The encoder keeps a {seq, stage cursor}
// mark per record and reclaims to the newest retired mark, so no wire field is needed.
// RingControl's stage cursor triple is unused by both sides: never attach a RingConsumer
// to RingCursorSet::Stage. Its local tail would never move, so PublishApplied /
// PublishRetired would walk both cursors backwards under this allocator.
void ReclaimStagedBytes();
// Staged bytes not yet reclaimed. The number MOBILEGL_IPC_STAGE_MB has to cover.
Uint64 StagedBytesInFlight() const;
@@ -350,27 +286,13 @@ namespace MobileGL::MG_Remote::Wire {
// 0 when this encoder has no command ring (a default-constructed one).
Uint64 MaxRecordBytesCap() const;
// ---- R-9's producer readings, and why they are three rather than one ---------------
//
// `CmdWraps()` counts SEG_CMD going ROUND: the number of times the producer's monotonic
// head crossed a multiple of the ring capacity and the byte area was reused from the
// start. It is what exit gate E3(e)'s small-ring lane asserts, because it is the one
// that is GUARANTEED once a workload writes more bytes than the ring holds, and
// therefore the one a lane can be red for not reaching.
//
// `CmdWrapPads()` counts the kRecPad fillers Reserve lays when a record would have
// STRADDLED that boundary. R-9's last clause - "a pad record does not advance seq, both
// sides must skip it and count again" - is about this one, and it is RECORDED rather
// than asserted: measured, a stream of clears and draws repeats at a stride that
// divides a power-of-two capacity exactly, so 1310824 bytes through a 1 MiB SEG_CMD
// produced one and a half trips round the ring and ZERO pads. A gate written against
// this number would have been red for the arithmetic of the record catalogue rather
// than for anything about the ring.
//
// `StageReclaimWaits()` counts allocations blocked on an outstanding retiredSeq
// after immediate reclamation still left insufficient space. Reclaiming bytes
// the consumer had already retired does not increment it. One allocation counts
// once even if it waits for several marks; this is staging, not command-ring pressure.
// ---- the producer's ring and staging counters --------------------------------------
// CmdWraps(): times the SEG_CMD head crossed a multiple of the ring capacity. Guaranteed
// once a workload writes more than the ring holds, so a test may assert it.
// CmdWrapPads(): kRecPad fillers laid when a record would straddle that boundary. Recorded,
// not asserted: a record stream whose stride divides the capacity wraps with zero pads.
// StageReclaimWaits(): allocations that had to wait for retiredSeq after reclaiming what
// was already retired; one per allocation, however many marks it waited for.
Uint64 CmdWraps() const;
Uint64 CmdWrapPads() const;
Uint64 StageReclaimWaits() const;
@@ -64,3 +64,13 @@
| `MGPDrawInfo` / `MGPDrawRange` | `DrawVbo`;multi-draw 范围与用户索引在变长尾,indirect 的 `DrawCount` 由 client 解析 |
每条 `kVarTail` 的 `set_*` 带 `ContentHash`,hash 未变就不发(§5.4)。
### 4.2 split 下的诚实规则
同一地址空间里 host 指针能解引用、以 host 地址为 `Offset` 的 blobref 能解析、persistent map 返回的地址能直接用;`inproc` 若借这些捷径变绿,`spawn` 无法复现。所以 split 下编解码一律按跨进程规则执行,`inproc` 也不例外(`MG_Remote/Wire/PipeWireCodec.h`):
1. encoder 把 `MGHostSpan::Ptr` 恒写 `nullptr`,字节放进 `SEG_STAGE`,由 `Seg` / `Offset` 指名。
2. `MGPBlobRef` 要么完整声明(真实 `Seg`、段内 `Offset`、非零 `Size`),要么三个字段全零。`Seg` 或 `Offset` 非零而 `Size == 0` 正是 monolith emitter 的形状(`Offset` 是 host 地址),split 下按损坏拒绝,不能读成"无 blob",否则未改造 emitter 的字节会被静默丢弃。
3. decoder 对 `Ptr != nullptr`、内容记录的 `Size == 0`、`Size != 0` 而 `Seg == kSegNone`、`Offset + Size` 越过段尾一律报 `ProtocolCorruption`(`CheckBlobIsHonest`、`RequireDeclaredBlob`、`CheckHostSpanIsHonest`)。记录是否带内容由 payload 的其他字段决定,所以"内容记录须有 blob"由 decoder 的逐 op 分支调用 `RequireDeclaredBlob` 检查。
4. T2 档下 `MGPipeApplyMapPersistent` 在 split 下恒返回 `nullptr`(§12),client 不拿 server 的地址。
5. `MOBILEGL_IPC_AUDIT=1` 时 server 把已退休记录的 `SEG_STAGE` 字节填成 `0xDD`,apply 之后仍持有指针的实现下一帧会读到 `0xDD`。
@@ -31,6 +31,16 @@
- **唯一允许同步 ack 的入口是 `glBufferStorage`**:`ResourceRespecify` 的 `kNeedsAck` 由逐记录谓词收窄到"不可变 **buffer**"(D-A2),纹理 OOM 在 monolith 里本来就推迟到 sync 时刻。respecify 带逐 level 作用域与"只换元数据"的形式,不误丢已接受的待上传。
- 其余错误一律晚到,走有序的 `OnGlError`。
#### 8.3.1 server 日志的转发队列
`MG_Remote/Transport/LogForward.h` 的 `LogForwardChannel`:`Offer` 只在通道互斥锁下拷贝一行入队,唯一的发送线程按入队顺序写帧,写 socket 时不持锁,所以 FATAL 以下的行花在转发上的只是一次加锁加一次拷贝(`LogForwardCounters::maxOfferNs`),与对端是否在读无关。server 自己的日志文件在转发之前写(`Log.cpp`),总有全部行。
- DEBUG / INFO / WARN 有损:使有损字节超过 `lossyQueueBytes` 的行丢弃并计数;缺口闭合时(下一条放得下的 ≤WARN 行、`LogFlush` ack 或 `Close`)通道自己插一条 WARN `LogForward{Dropped}`,写明条数、字节与起始时间。
- ERROR 无损、限速:令牌桶(`errorBurstLines` 起步、`errorLinesPerSecond` 补充),超速的行按序排队延迟、不丢;排队的错误字节超过 `errorQueueBytes`(有损上限的 8 倍)时不再入队,由排在下一个能入队条目之前的一条 ERROR `LogForward{Coalesced}` 代替。
- FATAL = ERROR + 等待:最多等 `fatalFlushMs`,直到发送线程写出它及之前的一切;一次等待超时后,在发送线程写出任何东西之前,后续 FATAL 不再等(`MGLOG_F` 也标记可存活的情形,可能连发)。`Log.cpp` 在日志锁外交出 FATAL 行。
- `LogFlush` ack(`OfferControlFrame`)排在之前提交的每一行之后,`MGPipeSyncPeerLog` 的「ack 到达时之前的行都已在文件里」因此成立;它排队期间发送线程不限速。
- `Close()` 不再收行,给队列 `closeDrainMs` 不限速发完;发送线程仍卡在写里时调用属主提供的 `unblock`(`ServerMain`:对控制 socket `shutdown(2)`),以便 join 而不是泄漏线程,剩余条目计为 abandoned。
- 只有 TCP server 形态转发(`ServerMain.cpp`,`MOBILEGL_IPC_LOG_FORWARD`);unix spawn server 与 inproc apply 线程自己写 `<base>.server.log`。
### 8.4 纹理重铸:字节全在 server 手里,没有拉取(P9 W2 撤销原「唯一的新停顿类」D-B6)
原设计假设 server 不保留纹素、重铸要把 level 从 client 拉回来。P5c 之后 server 有 `StagedTextureStore`,GPU 写过的层本来就在 GPU 图像里——**两个后端的重铸都不需要 client 的任何字节**:
+18
View File
@@ -20,10 +20,28 @@
一页 4 KiB,每个争用组一条 cache line,按写者分组(P6 lk):producer 线 `cmdHead` / `submittedSeq`;consumer 线两个 tail;四个 consumer 写的水位收成 `Progress`(`appliedSeq` / `retiredSeq` / `completedFrameSerial` / `presentAckSerial`);最后是 epoch、generation、park 标志与 `eventRingFull` / `eventDropped`。游标是单调字节计数、2 的幂掩码、永不重置;pad 记录处理 wrap;不可能的头 → `Fatal{ProtocolCorruption}`。
#### 11.2.1 五个水位
| 水位 | 由谁推进 | 含义 |
|---|---|---|
| `submittedSeq` | producer,发布之后 | 诊断量(client 领先 server 多少),没有人等它;放在 producer 的 cache line 上,`LinkProgress` 因此单写者 |
| `appliedSeq` | consumer,每 apply 一条记录 +1 | verb barrier 与每个 reply 等待读它,所以不得批量:批量会让等待方卡在已完成的工作上,或在未完成的工作上醒来 |
| `retiredSeq` | consumer,记录引用的 `SEG_STAGE` 字节用完后 | staging 分配器只在它之后回收;不超过 `appliedSeq` |
| `completedFrameSerial` | server,present 完成时 | 回收与老化等它;比 `appliedSeq` 落后一个 GPU 深度,不得混用 |
| `presentAckSerial` | server,归还 present credit 时 | present 节流等它;唯一限制延迟而非字节的背压 |
规则(`SessionRings.h` 的 `Watermark` 是唯一实现):所有等待用 `>=`,两侧都可能一次前进多步,`==` 会错过唤醒。水位只许晚发布、不许早发布:晚只是多等,早会让等待方静默使用尚未发生的工作,环上没有校验能发现;除 `appliedSeq` 外都可惰性发布,回退是 `Fatal{ProtocolCorruption}`。wrap 填充(`kRecPad`)不推进 seq:seq 就是 reply slot 的 id,只有一侧计数填充时两侧序号每次 wrap 偏一、静默读到别的调用的答案,所以两侧都在计数前跳过它(`RingConsumer::Pop`)。
### 11.3 双向 doorbell(`Doorbell.h`)
两个方向都是"先自旋、再置 parked、再阻塞",发布方仅当对端 parked 时敲;丢失唤醒由两个 `seq_cst` fence 关闭。`CondVarDoorbell`(同进程,带 `Kill()`)与 `SocketDoorbell`(跨进程,对端关闭即死亡检测)。stream 数据面上门铃是本地条件变量,由读线程敲。
#### 11.3.1 丢失唤醒为什么靠两道 fence
等待方:置 park 标志 → `std::atomic_thread_fence(seq_cst)` → 重测条件(`Doorbell::Wait`);通知方:发布水位 → 同样的 fence → 读 park 标志(`NotifyIfParked`)。两道 fence 都在 seq_cst 全序里、必有先后,按 [atomics.order] 至少一侧看得见另一侧的写,所以标志自身的读写可以是 relaxed。
只把标志的 store / load 改成 seq_cst 不够:Dekker 论证要求四个访问都在全序里,而水位发布是 release store(`RingProducer::Publish`)、条件重测是 acquire load。x86 上 release store 是普通 MOV,还停在 store buffer 里时,对标志的普通 MOV 读就可能读到 0,通知方于是不敲门铃,等待方在旧水位上永久 park;ARMv8 上 STLR→LDAR 是 RCsc,只是碰巧没事。所以两道 fence 都不可删,且 `NotifyIfParked` 必须在水位发布之后调用:fence 只约束它之前的访问。
### 11.4 控制面(`protocol.fbs`、`Framing.h`、`ITransport.h`)
- 一份 FlatBuffers schema:热路径是 `struct` 直接进 ring(与 POD 逐条 `static_assert` 对齐);罕见 / 变长 / 需演进的走控制连接的 `CtrlMsg`(`Hello`、`Welcome`、`Refuse`、`CapsSnapshot`、`SurfaceOp/SurfaceReply`、`SessionFault`、`LogLine` …),union tag 只追加,控制修订号钉在 `sha256(protocol.fbs)` 上。
@@ -14,6 +14,18 @@
T2 下 client 侧推送三件套:不做 map/unmap 命令对(payload 带 `hasLiveHostWrites`);在每个 validate 点按块(`MOBILEGL_IPC_PERSISTENT_BLOCK_KB`,默认 64)推送可达的已映射 buffer,脏页追踪 + 哈希抑制只发变化的块(P5d);门 `PersistentCoherentMapScenario`。`mpr` 数的是每一次 `MapPersistent` 发射(铸成或拒绝都算),所以在 monolith 下也可断言。
### 12.1 采纳档的握手期结算
档在握手期、第一条 `map_persistent` 之前,由知道数据面的一侧结算(`MG_Remote/Transport/AdoptTier.h`)。T0 用不了从不是 `Fatal`,而是 T2 加一行:`MOBILEGL_IPC_ADOPT_TIER=0` 时是具名行,未设时只打 MGLOG_D,照样计数。
| 数据面 | 一侧 | knob 0 / 未设 | knob 1 |
|---|---|---|---|
| stream | 两端 | 只有 T2(不共享内存);knob 0 打 `Refuse{AdoptTierOnStream, "T0"}` | `Refuse{AdoptTierOnStream, "T1"}`,T2 |
| 共享段 | client | 在 `Hello` 的 `LinkTerms.adoptTier` 请求 T0(0;未设为 `kAdoptAskT0Default`);是否真跑 T0 在第一条 `map_persistent` 时读 `kCapAdoptT0`(`ClientSession::AdoptT0`) | `Refuse{AdoptTierClosed, "T1"}`,请求 T2 |
| 共享段 | server | 不看自己的 knob:按 client 的请求,在第一次原生 bind 时批准(`ServerSession::SettleAdoptT0AtBind`,之后的 `CapsSnapshot` 带 `kCapAdoptT0`)或具名说明原因 | 同左 |
T2 路径上的使用时检查 `Client::AdoptTierIsEmulate` 读回结算结果:已结算的会话照常走 T2;knob 0 / 1 下没有任何握手结算过的 `map_persistent`(无会话的调用方)具名中止为 `Fatal{UnimplementedAdoptTier}`,不静默当成 T2。
## 13. 回读、roundtrip 清单与验证
### 13.1 稳态零 roundtrip 与不可避免的阻塞点
@@ -10,6 +10,14 @@
- 线程:client v1 不加线程,编码在 GL 线程直接写 ring;server 有 io 线程与**终身持有原生 context** 的 `mgl-srv-apply`。全库无有效亲和性控制(Redmi 内核忽略 app 线程的 `sched_setaffinity`)。
- 拆机顺序:publish + server 排空 → 停 apply 线程 → 关传输 → client 排空编译池 → `MobileGL::Destroy()`(落地形状见 §17)。
### 14.1 apply 线程的亲和与自旋(`auto`)
`MOBILEGL_IPC_SERVER_AFFINITY=auto`(缺省)按会话对端(`Hello` 的 `DialMode`)与数据面选择 apply 线程的亲和与 park 前自旋(`MG_Remote/Server/ApplyThreadPolicy.h`,纯函数):同进程、fork 出的、拨入但走 stream 的对端 → 全部大核与 `MOBILEGL_IPC_SPIN_US`;拨入且走共享段的对端 → 一个核(`ReservedApplyCore`:多个 prime 核时取最低的;只有一个 prime 核时取另一个大核;否则不钉,绝不给唯一的 prime 核)与 `kConnectedPeerSpinUs`(除非显式设了 `MOBILEGL_IPC_SPIN_US`)。显式掩码或 `off` 对任何对端优先。
- 拨入的共享段 client 直接写 apply 线程自旋的那条环,两边又落在同两颗 prime 核上:Redmi 上共享段 23–26 fps、TCP 45–54,手工分开后 118。stream 数据面由 io 线程喂 apply 线程,apply 不在 client 的写上自旋(apply CPU 7.9 ms/帧,共享段 26.8),所以维持全部大核。
- 取最低而非最高的 prime 核:调度器把前台应用的热 GL 线程放在最高的 prime 核(44 次采样 43 次在 cpu7);apply 线程钉在那里 6.5 fps,钉在最低的 120 fps。
- 短自旋保留:立即 park(0)在所有量过的格子里都更慢(helper 路径 120 → 74 fps,shell 27 → 17)。
- 经 `ExternalClientHelper` 启动的 client 也被挡在保留核之外:supervisor 启动时公布该核(`ReservedApplyCoreForConfig`),broker 交给 helper;操作员给了掩码或 `off` 时不保留任何核。
## 15. 进程、EGL 与平台(P6 / P6.5 / P12)
### 15.1 启动与握手
@@ -19,6 +27,17 @@
- `Hello`(abi、后端类型、指纹、配置、令牌、`LinkTerms` 请求)→ `Welcome`(server 陈述的尺寸、段 / aux 名、`dataNonce`、真实 pid)或 `Refuse{code, detail, 对端值}`;握手路径零 abort。非 loopback 监听必须有令牌(≥16 字节、常量时间比较、数据面按 `dataNonce` 绑定到已认证的控制连接、fork 前认证;Ph,OQ-21 裁定不做 TLS)。
- TCP keepalive / 心跳失败**由传输层报告为挂断**,进 device-lost 闩;"闩绝不取自 apply 超时"不因此松动。冷启动期 server 发 `SurfaceProgress`,client 预算这段沉默(控制修订 2)。
#### 15.1.1 TCP supervisor 的预认证
TCP `--serve` 的 supervisor(`ServerMain.cpp` 的 `TcpSupervisor`,判定部分在 `PreAuthGate.h`)先认证、再把会话交出去(fork 子进程,或进程内线程);未认证的对端最多占一个有界的 pending 槽:
- 新连接在首帧完整之前是 pending。supervisor 在一个 poll 循环里同时从所有 pending 连接非阻塞地读,任何未认证对端都拖不住别人;只读到首帧末尾,之后的字节属于会话。
- 每个 pending 连接有 `MOBILEGL_IPC_PREAUTH_MS`(默认 2000)读完首帧,同时最多 `MOBILEGL_IPC_PREAUTH_MAX`(默认 8)个。队列按地址共享:满时新连接挤掉占槽最多的地址最老的那个;它自己的地址已占同样多时,被拒 `Busy` 的是它。
- Hello 在 supervisor 里认证、令牌先于指纹:未认证的对端只收到 `Refuse{Authentication}`,得不到本构建的 wire / build 指纹。只为已认证的 Hello 交出会话,并把这一帧交给会话。
- 失败(令牌错、首帧畸形、超时、静默占槽超过 `kPreAuthProbeGraceMs` 后关闭或被挤掉)记在对端地址上;满 `MOBILEGL_IPC_AUTH_BACKOFF_AFTER`(默认 5,0 = 关)次后该地址在 accept 时、读任何字节之前即被拒,窗口自 `MOBILEGL_IPC_AUTH_BACKOFF_MS`(默认 1000)起逐次翻倍、上限一分钟;该地址认证成功一次即清零。宽限期内无字节就关闭的端口探测、指向无活会话的 DataBind、退避本身发出的拒绝都不算失败。
- DataBind 经 hand-off 连同 nonce 转给活会话(会话比较 nonce);没有活会话时由 supervisor 具名拒绝。已认证的第二个 Hello 收到 `Busy`。
- 判定部分不含 I/O(`MG_Remote/Server/PreAuthGate.h`),由 `PreAuthGateTest` 覆盖;早于 `kAuthBackoffForgetMs` 的失败不再计入退避。
### 15.2 Android
- 交付链(spike A 已证):APK 唯一可 exec 的位置是 `lib/<abi>/`,server 以 `add_executable` + `lib*.so` 命名构建;从 `untrusted_app` 进程 `fork`+`execve` 同域、零 avc denial;`posix_spawn` 在 minSdk 26 不可用。一份共享库两个角色(stub `dlopen` + `dlsym("mobilegl_server_main")`)。
@@ -57,3 +76,12 @@ server 分配并以 dma-buf 描述符导出的颜色缓冲,让一个 client
| `SetDamageRegion` | `eglSetDamageRegionKHR`:本帧将画的区域 |
| `Flush` | 往图像里渲染的会话(X server 的 glamor)的 `glFlush` / `glFinish`:自上个边界用过的图像按 flush 的 fence 发布为已写、已读(隐式同步),发布后回答 |
| `NativeFence` | `EGL_ANDROID_native_fence_sync`:会话已提交工作完成时 signal 的 sync_file(写图像的会话同时做 `Flush` 的发布),经 aux socket 先于 reply 送达,`Format = kMGPNativeFenceFdFollows`;`Format = 0` 表示 server 给不出描述符,reply 发出时工作已完成 |
### 15.5 EGL 归属:apply 线程终身持有原生 context
split 下应用线程从不调用驱动的 `eglMakeCurrent`:`BackendObject_Remote` 的 EGL 虚函数调用 `ServerLoop.h` 的 `Server*` 转发函数,每个都经 `RunSurfaceControlFrame` 成为阻塞控制请求,由 `mgl-srv-apply` 对 server 私有的后端对象执行。
- 为什么是这条线程:DirectGLES 的 `IsBackendContextCurrentOnThisThread()` 与 `Managers.cpp` 的 `CanTouchGLNow()` 守卫答「否」时静默降级——fence 恒为已 signal、query 返回空句柄、`Present` 不建帧 fence(buffer pool 的回收水位不再前进)、persistent map 拒绝采纳。apply 线程终身持有 context,这些守卫在 server 上都答「是」。
- 原生 `eglMakeCurrent` 只在 apply 线程当前绑定的 tuple 真正改变时运行,去重分两层:apply 线程上的 `ServerLoop::ApplyMakeCurrent` 用 `ClassifyEglMakeCurrent` 与上次绑定的 tuple 比较,相同即 no-op、不重发 caps,不同则转发并重发 caps 快照,client 的 release-current 只记录(`ClientReleaseCount`)不转发;`BackendObject_DirectGLES::MakeEGLCurrent` 在有传输时,若目标 draw surface 已是本线程原生当前、且虚 tuple 正是那次绑定所为(或那次绑定是 surface 自己的创建),跳过原生调用。同一 surface 上换虚 context 仍原生重绑,因为 `MakeCurrent` 的失效针对的是变了的前端 context。只记一个 tuple:线程上同时只有一个 current context,A → B → A 是三次原生绑定。
- 在原生绑定可能已改变、或 tuple 指向的 context 可能已不存在的每个事件之后遗忘 tuple(`ServerLoop::ForgetCurrentTuple`):`ReleaseEGLResources`、释放它指向的 surface、surface(重)建(创建时原生绑定新 surface)、后端销毁。EGL 句柄值会复用;记住死 tuple 的代价是静默地没有 current context,遗忘的代价只是一次多余的转发。
- `ReleaseEGLResources` 与 `~BackendObject_DirectGLES` 必须阻塞:前者本身是控制请求,后者在 apply 线程退出前于该线程执行,`ServerLoop::Stop()` 等它结束;否则 `MobileGL::Destroy()` 会在 server 仍持有 context 时继续往下拆。
- 对照 `ServerLoopEglTest.MakeCurrentBindsNativelyOncePerContextAndNeverForwardsAClientRelease` 在 EGL 函数表处计数:surface 创建 + 两次相同 make-current + 一次 client release + release 后再绑定 = 一次原生 `eglMakeCurrent`、零次原生 release。