diff --git a/MobileGL/MG_Remote/CONTRACT-P6.md b/MobileGL/MG_Remote/CONTRACT-P6.md new file mode 100644 index 00000000..a95aa59d --- /dev/null +++ b/MobileGL/MG_Remote/CONTRACT-P6.md @@ -0,0 +1,627 @@ +# CONTRACT-P6 — the backend runs in a second process + +Authority: this file, beside `CONTRACT-P5.md` (table 0, byte carriers, field ownership, R-1…R-17), +`CONTRACT-P5B.md` (the class-C slots), `CONTRACT-P5C.md` (rule E, the two named exemptions, SEG_EVENT, +the guards), `CONTRACT-P5E.md` (rule F, the barriered predicate, the wait rule) and +`CONTRACT-MAGMA-RUNAHEAD.md`. Where it disagrees with any of them this file is newer and wins; §10 +lists every such place. Base: `feat/disaggregated @ 785fed0b`. Every `file:line` was read at that +commit; paths are under `MobileGL/` unless they start with `docs/`. + +**How to change it.** Package `c6`'s file, edited by the integrator first. A P6 package +(`lk` / `hs` / `so` / `sm` / `cp` / `dl` / `st` / `t6`) that needs a row changed goes through the +integrator; the packages compile against the rows below from day one. + +**What this file is written from.** [`a6-audit-v1.md`](../../docs/Disaggregated/notes/p6/a6-audit-v1.md) +(198 rows, seven items, each adversarially re-resolved) and +[`a6-link-experiment.md`](../../docs/Disaggregated/notes/p6/a6-link-experiment.md) (the 184 symbols). +It supersedes [`P6-CONTRACT-DRAFT.md`](../../docs/Disaggregated/P6-CONTRACT-DRAFT.md), which was +written before its own audit and is **wrong in four places** (§10.1). The re-scoping against the +eventual client-in-a-VM end state is +[`P6-ENDSTATE-REVIEW.md`](../../docs/Disaggregated/P6-ENDSTATE-REVIEW.md); this file takes from it +only what P6 must not preclude, and §11 says what P6 explicitly does not take on. + +--- + +## §0 What c6 is, and the rule above every row + +`c6` lands **this file and two headers**. It changes no behaviour, adds no caller, and moves no +byte on any wire. Every rule below that describes an action names the package that implements it. +A rule with no package named is a statement about the tree as it already is. + +**Rule G — a role may not name a process-local handle.** Rule E forbade naming the other role's +*memory*; rule G extends it to *identity*. A value whose meaning comes from the owning process's +kernel or loader state — `EGLDisplay`, `EGLSurface`, `EGLContext`, `ANativeWindow*`, any `void*` +window handle, an fd number, a pid, a mapped address — either crosses as a **token** defined in §2, +or does not cross. Descriptors cross only by a mechanism the transport **declares it supports**, and +where the transport declares none they do not cross at all and the bytes are transferred instead; +a transport may not invent a third answer. + +> The draft hard-coded `SCM_RIGHTS` inside rule G. That is one transport's implementation of one +> clause, not the rule, and writing it into the rule is what would make the rule wrong the first +> time a transport has no descriptor passing at all. + +**Rule H — a value whose meaning comes from the peer's window system never crosses. Permanently.** +Not "until P12". P12 gives the *server* its own display; it does not give the client's window a way +across. §6 is rule H's whole enforcement. + +**Rule G's teeth** are §9's arm-proof gate: a spawn lane that silently ran monolith satisfies every +other gate in this file. + +--- + +## §1 The one fact that makes c6 possible: `Spawn` is unreachable today + +`ConfigLoader.cpp:339-346` is the only writer of `MG_Config::Transport` for the string `spawn`, and +it writes **`TransportMode::Monolith`** at `:344`, with a log line saying P5 does not implement it. + +Two consequences, and the second is why this file can exist without changing behaviour: + +1. **`ConfigLoader`'s named refusal cannot falsify itself.** It lands on the same value a server's + resting state would have, so "the child was forced to monolith" and "the parent fell back" are + one state. `sm` fixes this; until then, no lane can prove it ran spawn (§9.5). +2. **Every rule in this file conditioned on `Transport == Spawn` is vacuous in c6's own tree.** It + constrains a state no process can enter. That is how D1 (§3.1) is stated normatively while + changing nothing, and `c6`'s gate pins the vacuity so the `ConfigLoader` change cannot land early. + +--- + +## §2 Table 0 additions + +| row | encoding | invalid | consumer | package | +|---|---|---|---|---| +| `DisplayToken` / `SurfaceToken` / `ContextToken` | `Uint64`, minted by the client, dense from 1, never reused in a session. Server keeps token → native handle; it never sees the client's EGL values. `protocol.fbs`'s `display` / `surface` / `context` are already `ulong` and are these. **The client already mints them** (`MG_State/EGLState/Core.h`'s `EncodeHandle`), contrary to `Server/SurfaceControlFrame.h:26-29`, which says inproc bit-casts the handle — that comment is corrected by `cp`. | `0` on any op but `ReleaseCurrent` → `Fatal{ProtocolCorruption}` | the server's surface map | `cp` | +| `SurfaceOpKind::InitCapabilities = 11` | **Appended.** The draft claimed `ServerInitCapabilities` needs no wire form because "`CapsSnapshot` answers it". The *answer* is a `CapsSnapshot`; the **request** has a live client caller (`Client/BackendObject_Remote.cpp:126`) and the codec **refuses to encode it** (`Protocol/SurfaceOpCodec.cpp:119-122`, `InprocOnlyOpOnTheWire`). A spawn client cannot ask. | unknown tag → `Fatal{ProtocolCorruption}`, never ignored | the server's control pump | `cp` | +| `SurfaceReply.result: int` | **Appended, id 5.** Carries the `MobileGLResult` that `ServerApplyWireSurfaceOp` already computes and drops. Without it a client cannot tell a server that died mid-op from a legitimate refusal, and therefore cannot choose between arming the device-lost latch and returning `EGL_FALSE`. | — | the client's control demultiplexer | `hs` | +| `SurfaceReply.defaultFb` | **`(deprecated)`.** The encoder passes a literal `0` offset (`SurfaceOpCodec.cpp:184`) and the decoder never reads it; `kEventSurfaceChanged` is the landed carrier (P5f `fc`). The slot is **burned, never deleted** — a deleted field frees its vtable slot for the next append. | — | nobody, by construction | `hs` | +| `LinkTerms{dataPlane, wireForm, maxReplyBytes, cmdWindowBytes, stageWindowBytes}` | Appended to `Hello`/`Welcome`. `dataPlane` has exactly **one** legal value in P6, `SharedSegments`; `wireForm` exactly one, `StructImage`. Anything else is refused **by name**. The four sizes are **stated by the SERVER**. | any other value → `Refuse{ProtocolMismatch}` | the handshake | `hs` | +| `Refuse{code, detail, peer values}` | Appended `CtrlMsg` union tag. A handshake disagreement is an **answer**, not an abort (§5.1). | — | both sides | `hs` | +| `wireFingerprint` / `buildFingerprint` | The single `abiFingerprint` splits. §4. | mismatch → `Refuse`, never `Fatal` | the handshake | `hs` | +| `FatalCode` | **Unchanged**, 7 values (`protocol.fbs:248-256`). `table Fatal` appends `family: string`. §5.2. | — | — | `dl` | + +All schema edits are **append-only**. A FlatBuffers table field's id is its vtable slot and an enum +value **is** the wire value; retired fields become `(deprecated)`, never disappear. `CapsSnapshot`'s +four deprecated fields are the worked precedent and their reasoning is in `protocol.fbs` itself. + +> **`hs` implementation note, from c6's review.** `flatc` drops deprecated fields from the generated +> `Create*` helper. Deprecating `defaultFb` while appending `result` therefore changes +> `CreateSurfaceReply`'s arity, and the existing 6-argument call at `SurfaceOpCodec.cpp:183-184` +> would **silently bind its `/*defaultFb=*/0` to `result`**. `hs` converts that call site to +> `SurfaceReplyBuilder` with explicit `add_*` in the same commit, so a positional mistake is +> impossible rather than merely unlikely. + +--- + +## §3 Role, Dial, and the six predicates + +### 3.1 D1 — what `Transport` reads as in the server process + +**`TransportMode::Spawn`. The child does not force `Monolith`.** + +`ARCHITECTURE.md:488`'s literal instruction ("`mobilegl_server_main` hard-sets `MG_Config::Transport` +to `Monolith` before reaching `Init()`") is **amended by this file**. a6 measured why: 226 live lines +test `Transport != Monolith` to select the **server** arm, 148 of them in `MG_Backend`. Forcing +`Monolith` flips all of them to frontend glue in the one process with no frontend. The sharpest +instance is `PipeInputs::IsLive()` (`MG_Impl/Pipe/PipeFill.cpp:2135`): forced monolith, it reads +`MG_State::pGLContext`, which is null there, so **58 `MGB_CTX_LIVE` guards all answer "not live"**. + +Anti-recursion moves to a **separate, non-inherited axis**: `MG_Config::Dial == DialMode::No` in the +child. The envp scrub stays as the second, independent catch, and S3 (§9) still falsifies it — +and the diagnostic must **name which of the two fired**, or the double safety is untestable as two +things. Package: `sm`. + +### 3.2 D1c — there are six predicates, not three + +a6 proposed three. Three do not cover the tree. + +| predicate | asks | pull-build shape | +|---|---|---| +| `MGPipeSplitActive()` | do records cross a role boundary at all | `#else` returns `false` | +| `MGPipeServerArm()` | am I executing as the server **right now** | `#else` returns `false` | +| `MGPipeShouldDial()` | should this process connect out | `#else` returns `false` | +| `MGPipeSessionLive()` | is a peer session live | role-defined; `#else` `false` | +| `MGPipeBlocksAreDistinct()` | do the two roles share one storage object | `#else` `false` | +| `MGPipeBackendIsLocal()` | is the backend in **this** process | `#else` `true` | + +`ContextEpoch::Server` and `ActiveXfbState`'s role index are **`MGPipeSplitActive()`** sites, not +`MGPipeServerArm()` sites: they are partition keys, and the `ServerArm` disjunct would make a +partition key thread-dependent. + +**D14 — all six must fold away in the pull build**, and the required shape is `SlotCaps.h`'s: the +non-disaggregated build gets a **different definition** under `#else`, not a runtime-false predicate. +`MGPipeSplitActive()` must additionally remain exactly one load of a process global and must never +be latched into a `static Bool`. G1 is 0/0/0/0 and `.text` unchanged; anything else is a c6 defect. +Package: `sm`. + +### 3.3 D13 — rename before introducing + +`MGPipeRoleSplitActive()` (`MG_Backend/MGPipe/PipeInputs.cpp:292`) means +`Ipc.RoleSplitState && Transport != Monolith` — the **rehearsal knob**, default off. It is renamed +to **`MGPipeRoleSplitRehearsalActive()`**, which is what it has always meant, and +`MGPipeBlocksAreDistinct()` is introduced beside it as the wider predicate. a6's shape — rename, then +widen the same function — is **overturned**: a widened function under a narrow name is a name that lies. + +Blast radius, measured at this head: **12 sites**, of which `DualBlockScenario.cpp`'s assertion +*message* embeds the old name and must be updated with it. Package: `sm`. + +### 3.4 D10 — the four guards that cannot arm in a server-only process + +| guard | today | becomes | package | +|---|---|---|---| +| `MGPipeApplierReset` layer 2 (`MG_Pipe/PipeApply.cpp:1353-1355`) | needs `ClientSession::Active() != nullptr`, permanently null in a server process | middle conjunct → `MGPipeSessionLive()`; third → `!MGPipeServerArm()` | `sm` | +| `MGPipeServerBlockNoteIdentity` (`PipeInputs.cpp:314`) | early-returns unless the rehearsal knob is on | gate → `MGPipeBlocksAreDistinct()` | `sm` | +| `CountBarrierPull`'s unconditional Fatal arm (`PipeInputs.cpp:174`) | knob-gated | rides `MGPipeBlocksAreDistinct()` | `sm` | +| the two `RefuseFromApplyThread` helpers (`MG_Impl/Pipe/SlotAllocator.cpp:29-57`) | `ServerLoop::OnApplyThread()` | OR'd with a process-role fact set once by `ServerMain` | `sm` | + +**The one that crashes rather than merely failing to arm.** With `MGPipeServerBlockNoteIdentity` +early-returning, a spawn server's `gPipeInputs.ContextIdentity()` stays `nullptr` for the life of the +process, and `DirectGLES.cpp:199-213`'s fb-slot memo does a **raw pointer compare with no generation** +against it — so `nullptr == nullptr` reads as a **cache hit** and the first call uses an +uninitialised slot. `PipeInputs.h:878-884`'s own comment says a named Fatal was intended there. +`sm` lands the gate change; `st` lands the named Fatal. + +--- + +## §4 The handshake (`hs`) + +### 4.1 One equality test answers three different questions today + +`AbiFingerprintInputs` (`Transport/SessionRings.h:544-566`) mixes wire properties, struct-layout +properties and pure build identity into one `Uint64`. It splits: + +**`wireFingerprint` — always compared.** Inputs, in order: +an ordered **catalogue digest** over `PipeCalls.def`'s `(opcode, payload struct name, call-flag row, +WaitClass)`; a **payload layout digest** derived from `PipeFields.def` (per member: name, `offsetof`, +`sizeof`); the record-header layout; both blob-codec version stamps; the format-capability table's +two extents; `kOpCount`; `MOBILEGL_ABI_VERSION`; endianness; pointer width; **and** +`sizeof(MGPCaps)` and `sizeof(DynamicBackendParameters)`. + +Those last two **stay compared** because they are wire facts, not build facts: `MGPWireRec_GetCaps` +is composed from `sizeof(MGPCaps)` (`MG_Pipe/generated/PipeWire.inc`). `sizeof(GLFunctionsTable)` is +**deleted** from the mix: it never crosses the wire, so adding one backend slot today invalidates +every peer for no wire reason. + +> **Why the layout digest is not optional.** `SessionRings.h:561-565`'s own comment says the git +> stamp is in the mix because *two builds of the same sizes can still disagree about a FIELD ORDER, +> which no sizeof can see*. A catalogue digest over `(opcode, struct name, flags, WaitClass)` sees +> neither a member reorder inside a payload struct nor a width change. So the widely-proposed "drop +> the git stamp, add a catalogue hash" is **wrong as usually stated** — it removes a guard and does +> not replace it. The layout digest is the replacement, and `gen_pipe.py` already CI-enforces that +> `PipeFields.def` names every member, so it is a generator addition and not a toolchain. + +**`buildFingerprint` — compared only while `Dial == Fork`.** Under fork the child *is* the same +binary, so comparing it catches a stale `MOBILEGL_IPC_SERVER_PATH` pointing at an old +`libMobileGLServer.so` — a real P6 failure mode. **P6's policy is therefore strictly stronger than +today's, not looser**; the end state is a policy flip, not a redesign. + +### 4.2 The missing build stamp + +a6 found `CMakeLists.txt:188-195` takes the git hash with **no `RESULT_VARIABLE` and no fallback**, so +a failed `git rev-parse` silently yields `""`. Verified live: building this worktree from WSL +produces `GIT_COMMIT_HASH_SHORT ""`, because the worktree's `.git` file holds a Windows path WSL's +git cannot resolve. Two builds from different commits then get **identical** fingerprints and the +stamp silently stops doing its documented job. + +**The fix may not touch `GIT_COMMIT_HASH_SHORT`.** That macro is baked into +`glGetString(GL_VERSION)` (`MG_Impl/GLImpl/Getter/GL_Getter.cpp:614`), so redefining it changes an +**application-visible GL string** and the pull build's `.rodata` — a behaviour leak and a G1 break +at once. Instead `MGGitHash.h.in` gains a **second** pair, `MOBILEGL_BUILD_STAMP_VALUE` and +`MOBILEGL_BUILD_STAMP_PRESENT`, consumed only by the fingerprint, and the CMake step gains a +`RESULT_VARIABLE`. A build with no stamp sets `PRESENT` to 0, and a `Dial == Fork` handshake whose +peer reports `PRESENT == 0` is a **named refusal**: under fork the two peers are supposed to be the +same binary, and without a stamp that premise cannot be checked at all. Package: `hs`. + +### 4.3 Two verified asymmetries `hs` also fixes + +- The client checks only `abiFingerprint` and **never `abiMajor`/`abiMinor`** (`ClientSession.cpp:566-568`). +- `Hello::pid` is hard-coded `0` and `Welcome::serverPid` echoes the client's, so it is always 0 + (`ClientSession.cpp:519-521`, `ServerSession.cpp:532-534`). §9.5's arm-proof gate wants the child + pid; `serverPid` is its natural carrier and must start carrying it. + +### 4.4 Segment geometry (D-size) + +`MOBILEGL_IPC_RING_MB` / `STAGE_MB` are read from the **client** process's env with ceilings of +1024 / 4096 MiB (`ConfigLoader.cpp:363-364`), while the **server** creates the segments. Under spawn +that is a guest environment variable sizing an allocation inside the renderer process. The four sizes +become **server-stated** `LinkTerms` fields; the client's values become a *request* the server may +clamp, and the clamped values are reported in `Welcome`. + +`ShmSegment::Adopt`'s `fstat` "do not trust the peer's declared size" check +(`Transport/ShmSegmentPosix.cpp:127-133`) is today the **only** bound, and it has no analogue on a +link with no fd. The compile-time ceiling that refuses an oversized announcement by name is +**P6.5's** to define; P6 does not pretend to have answered it. + +--- + +## §5 Death, refusal, and what P5e changed + +### 5.1 The handshake never aborts + +A version or terms disagreement is a `Refuse{code, detail, peer values}` control message plus +`MOBILEGL_ERR_PROTOCOL_MISMATCH` on **both** sides: the server sends `Refuse` and exits 0, the client +reaps the child and refuses **by name**, never falling back to monolith. Both `FatalAbiMismatch` call +sites go (`ServerSession.cpp:471-483`, `ClientSession.cpp:565-568`). The worked shape is eight lines +away in the same function: `ServerSession.cpp:456-466` already returns +`MOBILEGL_ERR_PROTOCOL_MISMATCH` for a malformed frame, with the correct `msg_as_Hello() != nullptr` +guard. Package: `hs`. + +### 5.2 `Fatal{}` keeps aborting in P6, and gains one funnel + +a6 censused **92 abort sites** under `MG_Remote/` carrying **30 distinct family words**, against a +**7-value** wire `FatalCode`. So `Session::Fail(FatalCode, detail)` as usually proposed **cannot carry +today's vocabulary in its first argument**. + +**D2 ruling.** The first argument is an internal `MGFatalFamily`, generated from a new +`MG_Remote/FatalFamilies.def` — one row per family word, each row carrying its `FatalCode` +projection. The **wire** keeps `FatalCode` unchanged; `table Fatal` appends `family: string`. The +projection is a **total table**, not a switch with a `default:` arm — this build has **no `-Werror` +anywhere** (verified: `CMakeLists.txt`, every `*.cmake`, both workflows), so a gate premised on +`-Wswitch` failing the build is fiction. + +`Session::Fail` stays `[[noreturn]]` in P6 and still ends in `abort()`. Log lines, CI greps and every +red-once are byte-identical. What the funnel buys, and what makes it worth `dl`'s two days: + +- **one** place that publishes a `SessionFault{family, detail, seq, op}` frame to the peer **before** + dying, so the guest's log names which record killed the session instead of reading a bare EOF; +- one telemetry point; +- a CI census gate refusing any **new** bare abort outside the funnel. + +**The policy flip is not P6's.** Making `Fail` return needs a real return path invented at every site +whose callers rely on `[[noreturn]]`, and a6 counted how many do. That is `Ph`'s (§11). + +Two sites carry **no `Fatal{` marker at all** (`Client/PersistentMapTracker.cpp:1052-1058`, +`Transport/WireLog.cpp:41-50`), so "the log stays verbatim" is not true of them and the family grep +cannot see them. `dl` gives each a family tag. + +### 5.3 The device-lost latch does not exist, and P6 writes it + +`P6-CONTRACT-DRAFT §5.1` says the latch "is implemented unchanged". It is not implemented at all: +`FatalCode::DeviceLost`/`ServerCrashed` have zero producers and zero consumers, +`glGetGraphicsResetStatus` returns `GL_NO_ERROR` unconditionally +(`MG_Impl/GLImpl/Getter/GL_Getter.cpp:2855-2861`), and `MOBILEGL_IPC_RESPAWN` / `IDLE_EXIT_S` are +parsed **nowhere** (only a comment at `Config.h:466`). Today a dead server leaves the client +silently `DECLINE`-ing every verb forever with `glGetError` clean. + +**It is a package (`dl`), not carriage.** The latch is session-scoped and set from +`Doorbell::Dead()`, **never from a timeout**. `MOBILEGL_IPC_RESPAWN=1` is a named refusal until some +stage implements the re-push. + +### 5.4 Slow, dead, and frozen are three states + +- A published, never-applied record is **discarded at the latch, not waited on**. +- **Slow and dead are distinguished without a timeout threshold.** A server one frame behind is + P5e's intended steady state; a server that is gone is `Fatal{ServerCrashed}`. `SocketDoorbell` + already latches `m_dead` on EOF — take the fact from there. +- **A frozen peer produces no hangup**, and a6 found the tree has no defence: the control-reply wait + `m_controlDone.wait(lock, pred)` (`Server/ServerLoop.cpp:724`) has **no deadline**, while the ring + side has `kBarrierTimeoutMs`. A SIGSTOP'd or freezer'd server wedges the GL thread inside + `eglMakeCurrent` with no diagnostic. + +**D5 ruling — two waits, two answers.** (a) The server's *in-process* mailbox wait gains a bound and +its expiry is `Fatal{ControlReplyTimeout, "@"}`. (b) The client's *cross-process* reply wait +is bounded by `MOBILEGL_IPC_CONTROL_TIMEOUT_MS` (default 5000) and its expiry is **not** fatal: the +client consults the doorbell's death latch — dead latches device-lost, alive-but-silent is a named +diagnostic. Packages: `cp` (a), `dl` (b). + +--- + +## §6 The control plane (`cp`) + +### 6.1 What `fc` landed, and what is left + +P5f package `fc` framed the twelve `Server*` forwarders as value-only `SurfaceControlFrame`s and +landed the wire codec. The draft concluded `cp` is "transport only". **Three things are not +transport**, and a6 found all three: + +1. The `InitCapabilities` **request** has no wire form (§2). +2. `SurfaceReply` cannot carry a `MobileGLResult` (§2). +3. **Neither side of the control plane is wired to a socket.** The apply thread's `ready` predicate + has exactly three terms — stop, `ControlIsPending()`, `cmdHead != LocalTail()` — and a socket is + not among them (`ServerLoop.cpp:398-402`), so a `SurfaceOp` arriving on the wire is read by + **nobody**. The client's pump consumes only `CapsSnapshot` and drops everything else with a WARN + (`ClientSession.cpp:1425-1432`). + +### 6.2 D4 — who reads the control socket + +A dedicated **`mgl-srv-io` thread**: `ReceiveFrame` → `DecodeWireSurfaceOp` → post into the +**existing** one-slot mailbox as an ordinary non-apply-thread poster. `ServerLoop.cpp:673-727` is not +changed. The io thread is additionally the server's **only** sender on the control transport: +`PublishCapsSnapshot` enqueues and the io thread drains one outbound FIFO — which is what fixes the +ordering hazard that `CapsSnapshot` is pushed from **inside the dispatch, on the apply thread, +mid-op** (`ServerLoop.cpp:959-969`). + +Rejected: adding a fourth term to the apply thread's `ready` predicate. That puts frame decode on the +thread holding the native context and makes every control arrival a wake of the applier. + +### 6.3 D7 — `seq` + +The **client** mints, always, dense from 1, never reused in a session. `seq == 0` arriving on the +wire is `Fatal{ProtocolCorruption, "SurfaceOp.seq"}` at the **decoder**, not at +`RunSurfaceControlFrame`, whose local mint (`ServerLoop.cpp:659-662`) stays and becomes reachable only +for a local, non-wire post. Correlation lives in the client's control demultiplexer; +`DecodeWireSurfaceReply` keeps its void signature and stops being the place correlation was expected. + +### 6.4 `ForgetCurrentTuple` + +The N-3 rule holds identically on the frame path: the forgetting calls are inside the dispatch +(`ApplySurfaceControlFrame`) and `ServerLoopTest`'s C7/N-3 controls drive them through framed posts +unchanged. No change. + +--- + +## §7 Windows — rule H's enforcement (`cp`) + +### 7.1 The blacklist is open, and the end state's first guest walks through it + +`WindowBackendForWireWindowKind` (`Protocol/SurfaceOpCodec.cpp:104-113`) **accepts** +`WindowKind::X11 → WindowBackend::X11` and `Win32Hwnd → Win32`. `DecodeWireSurfaceOp` (`:141-179`) +refuses only `AndroidNativeWindow` and `MetalLayer`. An accepted kind's `nativeToken` is copied +through and `UnpackWindowHandle` (`Server/ServerLoop.cpp:875-882`) casts it to `void*` +**unconditionally**, whence it reaches `eglCreateWindowSurface` in the server process. +`DetectWindowBackend()` is a compile-time `#if` chain (`MG_Impl/EGLImpl/EGLImpl.cpp:59-70`), so a +guest cannot avoid announcing its own window system. + +### 7.2 D8 — whitelist, and in P6 the accept set for a window op is empty + +- `X11` → `Fatal{UnmigratedSurface, "X11@P12"}`; `Win32Hwnd` → `"Win32Hwnd@P12"`. +- `None` on a **window** op → `Fatal{ProtocolCorruption, "SurfaceOp.windowKind"}`. Today it is + accepted, maps to `WindowBackend::Unknown`, and the raw token is copied through anyway. +- Separately and independently: `nativeToken != 0` on **any** op → + `Fatal{ProtocolCorruption, "SurfaceOp.nativeToken"}`. +- `Pbuffer` / `Surfaceless` are surface **shapes**, not window backends, and remain the only things + P6 lands. + +**Do not append `WindowKind::ServerOwned` in P6.** The schema is append-only, so an enumerator +appended now and re-meant later is a wire value burned; P6 has no consumer for it. P12 appends it. + +### 7.3 The client emits unconditionally, and P6 must decide what that costs + +`Client/BackendObject_Remote.cpp:214-216` posts `SetWindowHandle` with no split-arm check, so on a +real Android app the **first `eglCreateWindowSurface` aborts the server process** and the client sees +only a dead peer. `cp` mirrors the refusal on the **client** side, so the diagnostic names the cause +in the process that caused it. This is P6's, not P12's, because P6's device lanes run on a phone. + +### 7.4 `m_windowHandle` has four stores, not one + +a6's precise shape, correcting the corpus (and `P6-ENDSTATE-REVIEW §2.5`): four stores +(`MG_Backend/BackendObject.cpp:475`, `:365`, `:206`, `:207`), five reads, and `:283` is a **call**, +not a store. `.Width`/`.Height` have **no readers**. The smallest latch point for a server-owned +window is `RegisterEGLWindowSurface` (`:232-238`), and the non-obvious fourth edit is the +`sameHandle` dedup key (`BackendObject_DirectGLES.cpp:962-967`), which under server ownership never +matches and would rebuild the native context on every `eglCreateWindowSurface`. Recorded here for +P12; P6 changes none of it. + +--- + +## §8 The data plane (`lk` implements, `c6` declares) + +### 8.1 Why a seam at all + +`ITransport.h:16-20` says in its own header that the hot path bypasses it: it is the **control** +plane, and that scope is already correct for a socket. Below it there is **no interface of any kind** — +`Ring.h`, `ReplySlot.h` and `EventRing.h` take raw `void* base + capacity`. So "swap the transport" +has nothing to swap. + +`c6` declares `Transport/ILink.h` and `Transport/StreamLink.h`. `lk` implements `ShmLink` by moving +today's `SessionSegments` / `Ring` / `ReplySlotPool` / `EventRing` behind the interface, line for +line, with **zero behaviour change**. + +### 8.2 The seam is a setup-time interface, not a hot-path one + +`ILink` hands out POD handles **once at attach** and then stays out of the way. No `ILink` virtual is +called per record, per publish, or inside any spin predicate. Three grounds, and the second is +decisive: + +1. Two virtuals per record at the 4000 records/frame that `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% run-to-run dispersion, **eight times the effect**, so the gate + `P6-ENDSTATE-REVIEW` demands could only ever print "within noise" — a green that proves nothing. +2. **The spin predicate forbids it outright.** `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 10⁵–10⁶ times per frame and reads the watermarks + straight off the 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 single `Reserve`, and + virtualising `Reserve` alone would force either four more virtuals or moving R-10's wrap counters + behind the seam — and those are deliberately encoder-owned. + +**The gate is therefore an `objdump` call-count of the encode path, not a wall clock**: after `lk` the +path must have no more out-of-line calls than today. That is falsifiable; "within noise" is not. + +### 8.3 `RingControl`'s regroup is `lk`'s, and it is a wire break the day a second build exists + +The watermark cache line is `{appliedSeq, submittedSeq, retiredSeq, completedFrameSerial, +presentAckSerial}`; `eventRingFull`/`eventDropped` sit in a **separate** `alignas(64)` group with +`serverEpoch`/`ringGeneration`/the two park flags. And `submittedSeq` is **producer-written** +(`Ring.h:50`) while the other four are consumer-written — so the group has **mixed writers**, and any +"single-writer progress block aliased over the watermark line" is unsound. + +`lk` therefore **regroups**: `submittedSeq` moves to the producer's own line (`Ring.h:45-50` states +it is diagnostic and nobody waits on it), `eventRingFull`/`eventDropped` are promoted into the +watermark group, and the group becomes a named `LinkProgress` member with per-field `offsetof` +static_asserts. + +It is free **today** — both peers are the same binary, enforced by the fingerprint — and a wire break +the day a second build exists. It is **`lk`'s and not `c6`'s** because the rename blast radius is +**109 sites in production alone**, not the 39 an earlier draft assumed, and because five of them sit +behind `#if defined(MGITEST_SPLIT_RUNTIME_PEEK) && !defined(__ANDROID__)` — an Android configure +compiles them out, so a missed rename is green on device and red only on a desktop split-peek build. +`lk` publishes the per-file itemisation before it starts. + +### 8.4 What `StreamLink` is for + +Every method is a named refusal, `Fatal{UnmigratedDataPlane, "StreamLink@P6.5"}`. It exists so the +seam's adequacy is a **compile**, not an argument: if `ILink` cannot name what a byte stream needs, +`StreamLink` cannot be declared against it. Writing it is what revealed that the seam needs +`FlushProgress()`, `ResolveSpan()` and a reply pair — three methods the brief's own list +(progress transmission, send window, flush) did not name. + +`c6`'s red-once: delete one `ILink` method `StreamLink` declares and the build goes red. + +### 8.5 The purity gate (`lk`) + +After `lk`, the identifiers `RingControl`, `RingProducer`, `RingConsumer`, `ReplySlotPool` and +`EventRingConsumer`, and any `m_shm.` expression, appear **only** inside `Transport/ShmLink.*` and +the transport's own tests. Same shape as the existing A/B/C purity gates. The baseline is recomputed +mechanically at `lk`'s head and published per file — the earlier draft's ratchet number was wrong by +a factor of three, and a ratchet installed at the wrong number is red on day one for reasons that +have nothing to do with a leak. + +### 8.6 `SEG_STAGE`'s cursors are deliberately dead + +`Ring.h:105-129` states it and a test pins it: `SEG_STAGE` is **not a ring**; staging is an +encoder-local linear allocator reclaiming on `retiredSeq`, and all three stage cursors stay zero for +the whole of P5. A stream link's send window either revives them **completely** or does not use them; +the header names the middle state as *a guaranteed hang rather than a slow path*. P6.5's, recorded +here so it is not rediscovered. + +--- + +## §9 The gate + +Beyond the five-part gate (`docs/Disaggregated/ARCHITECTURE.md` §13.2): + +1. **G1** — pull build 0/0/0/0, `.text` unchanged. Every predicate in §3.2 folds away. +2. **G2/G14** — `integration-spawn`'s case-name set identical to `integration-split`'s. +3. `integration-spawn` green at the same count as `integration-split`. +4. **Process tree, mechanical** — exactly one extra child while running, zero after; `HeadlessGL`'s + fork pre-check leaves no orphan. +5. **Arm proof (ID-124)** — every spawn entry records the child pid and `transport=spawn` in its own + private log. A spawn lane that fell back to monolith passes items 1–4. §1 is why this gate is not + optional: today the fallback and the intended state are **the same value**. +6. **Red-once per package (R-16)**, one named pair each. +7. **Device** — Redmi `2f7cbe2e`, reboot-clean, paired A/B in one thermal window. +8. **Performance recorded, not gated**, but three numbers are mandatory: the socket doorbell's cost + against `inproc`'s condvar; `SEG_STAGE` bytes/frame; and records/frame **post-chunking**. The last + two are takeable on the existing inproc lane today and `MEASUREMENTS.md:342` says in as many words + that no post-chunking distribution exists. + +> Item 8's wording must be able to **accept a negative regression without looking suspicious**: today +> the client spins ~1600 times/frame on a shared cache line with 99.8% of waits resolved there, and a +> socket link has no such line. Converting that into ~4 blocking reads/frame may be **cheaper**. + +Negative controls, each run red once: + +| # | knob | what it must falsify | package | +|---|---|---|---| +| S1 | `MOBILEGL_IPC_SERVER_PATH=/nonexistent` | named refusal, not a silent monolith fallback | `sm` | +| S2 | `kill -9` the server mid-frame | the latch; the client never hangs and never keeps submitting. Red-once the **wrong implementation** too: set the latch from a timeout and the paired control "a server one frame behind is the normal state" must go red | `dl` | +| S3 | child envp not scrubbed | `Dial = No` catches it **and the diagnostic names which of the two catches fired** | `sm` | +| S4 | inherited P5f epoch invalidation disabled | a replaced context inherits stale synced state; a still-live XFB lifetime retains its paused span | `st` | +| S5 | `WindowKind::X11` with a plausible XID reaches the server | named Fatal, **and the gate falsifies that the token reached `UnpackWindowHandle`**. Today's S5 tests only `AndroidNativeWindow` and stays green with §7.1's hole open | `cp` | +| S6 | a peer offers `LinkTerms.dataPlane = StreamOnly` | refused by name, **and the server survives to serve the next connection** | `hs` | +| S7 | a bare `abort()` added outside `Session::Fail` | the census gate goes red | `dl` | +| S8 | — | `integration-spawn` asserts `SessionFaultCount() == 0` over a whole run: the **good-path** assertion E1/ID-122 wanted and could not express, and which S5/S6 can falsify | `t6` | + +--- + +## §10 Amendments + +### 10.1 To `P6-CONTRACT-DRAFT.md`, which this file supersedes + +1. §1's "ABI fingerprint | Unchanged … same-machine, same-binary" → §4. +2. §5.1's "The latch … is implemented unchanged" → **false**; §5.3. +3. §4's "P6's remainder is TRANSPORT ONLY" → **false in three ways**; §6.1. +4. §7's "a transport swap and nothing else" → **no longer true**. This file adds `lk`, `hs` and `dl` + to the package list and says so here rather than smuggling it. + +### 10.2 To the earlier contracts + +1. `CONTRACT-P5C.md:537-539` — EGL value framing was discharged by P5f `fc`, the unpack/render-state + epoch work by P5f `fs`. P6 carries the existing frames over the socket and verifies the inherited + lifecycle rules in the second process. +2. `CONTRACT-P5C.md:56` — P6's multi-context shape. **Declined**: P6 stays single-context. §11. +3. `Transport/ReplySlot.h`'s "P6 debt" for the 2 MiB reply cap and chunked readback. **Declined**: + P9's. Note `P6-SPAWN-PLAN §8.5` already reassigned it and the header was never updated — two + documents in the tree disagreed; this file settles it. +4. `Ring.h:71-77` — `appliedSeq` is excluded from lazy publication **by name**. P6 does not change + the behaviour; it records that any future stream link **amends** this rule rather than inferring + around it. Several designs have argued the late-never-early invariant licenses lazy `appliedSeq`; + it explicitly carves `appliedSeq` out. +5. `ARCHITECTURE.md:488` — the child-forces-monolith rule. **Amended**; §3.1. +6. `ARCHITECTURE.md:9` and `MG_Backend/MGPipe/PipeInputs.h:281`, `:744`, `:957`, and + `P5F-WIRE-COMPLETENESS §1.4` — "the server does not link `MG_Impl`". **False at this head.** §12. + +--- + +## §11 Not P6 + +Real window arrival and the `android:process=":mgl"` Service (P12); multi-context (P12); chunked +readback and the reply-slot pool (P9); `DynamicBackendParameters`' fixed-width rewrite (P7); +`MOBILEGL_IPC_POLL_ESCALATE` (P10); remaining monolith-only frontend-object / twin-registry glue +(P3b/P4b) and the named P7 functionality debts; Windows `pipe:` / `unix:`; the 511 class-C entries. + +**The stream data plane is P6.5**, and it must land before P11 and before P9 picks a mechanism. +**Untrusted-guest hardening is `Ph`**, and it must land before any shipping server app accepts a +connection from a guest it did not produce — including the `Fatal`-policy flip (§5.2), the +handle-slot budget, and the caps in §12. + +E1's re-specification (ID-122) is **not** discharged by P6 and does not become P6's because P6 starts. + +--- + +## §12 Recorded debt + +### 12.1 The server image links `MG_Impl` in P6 (D12) + +**P6's server image is the whole `libMobileGL.so`** — which is what `ARCHITECTURE.md:496`'s "one +shared library, two roles" always meant. The assertions that the server does not link `MG_Impl` are +false and become debt with named owners. + +Measured: **184 symbols**, 177 of them real function calls, that the server set needs and only +`MG_Impl` / `MG_State` / `MG_Remote/Client` define. Owners: **P7 101**, **P3b/P4b 14**, both backends +**60**, **P6's own 6**. There is additionally **no module target to link** — `SOURCE_FILES` is one +flat list feeding both library targets — so establishing module boundaries is itself named work, +owed to P13. Method and full symbol list: +[`a6-link-experiment.md`](../../docs/Disaggregated/notes/p6/a6-link-experiment.md). + +**P6's own six** are not P7's and are `sm`/`lk`'s to clear: `Server/PipeApplier.cpp` calls +`Client::ClientSession::NoteApplyThreadEnteredApplier()` / `NoteApplyThreadLeftApplier()`; +`Wire/PipeWireCodec.cpp` needs `MG_State::GLState::DecodeProgramArchive` and +`Client::AdoptTierIsEmulate()`; `MG_Pipe/PipeApply.cpp` needs `Client::ClientSession::Active()` and +`AdoptTierIsEmulate()`. + +**Gate**: a ratchet recomputes the 184 in CI and asserts it only ever falls. + +### 12.2 D11 — five unbounded peer-driven allocations, capped, with no `try`/`catch` + +Every peer-declared count is bounded **before** it reaches an allocator and the bound is a named +refusal. `MG_Remote` gets no `try`/`catch`: a cap that is checked is better than an exception that is +caught, and the funnel (§5.2) is where the diagnostic lives. + +| site | today | package | +|---|---|---| +| `MG_Pipe/PipeApply.cpp:1563-1569` | resizes to a peer-supplied `Cso.Slot + 1` with **no slot limit**; every sibling create-family goes through `RecordAt`'s bounded path. `Slot = 0xFFFFFFFE` asks for ~1.7 TB | `dl` | +| `Server/PipeApplier.cpp:416-441` | readback scratch resized from `Box.W * Box.H * bpp` **before** any size gate. 65535² RGBA8 asks for ~17 GB | `dl` | +| `MG_State/.../ProgramArtifactsCodec.cpp:166-175`, `:216-223` | count bounded by remaining **bytes**, then multiplied by `sizeof(element)` | `dl` | +| `Server/StagedTextureStore.h` | resize not bounded against the level's declared extent | `Ph` | +| `SEG_EVENT` overflow | a guest that merely stops draining kills the host renderer in 60 s | `Ph` | + +### 12.3 One guest per server process (D9) + +For the whole of P6, one server process serves exactly one client session, and it is a **structural +fact, not a preference**: `g_Display`/`g_Context`/`g_Surface`/`g_Config` are per-process file-statics +and `InitDisplayAndContext` opens with an unconditional `DestroyEGLContext()` whose last act is +`eglTerminate` — a **process-wide** EGL teardown. `ServerSession::Accept`'s same-object arm +(`ServerSession.cpp:425`) returns `MOBILEGL_ERR_INVALID_ARGUMENT` **with no log line at all**; `sm` +gives it one. Under `Dial == Fork` a second guest is unreachable by construction, so the invariant +becomes falsifiable only under `Dial == Connect`, which P6 does not land. + +### 12.4 Settled by ruling rather than by experiment + +- **Who runs the applier in the server process**: `ServerMain` **must** drive it through + `ServerLoop::Start()`, so `g_applyThreadKey` is published and the dozen `OnApplyThread()` guards + keep meaning what they mean. Owner `sm`; red-once drives the applier without `Start()`. +- **The server's exit primitive**: `_exit`, after an explicit log flush. It decides whether + `g_processTeardown` is set and therefore whether seven twin destructors take their early-out. + Owner `sm`. +- **The handshake asserts the resolved subsystem mask**; a mismatch is `Refuse`, not `Fatal`. The + envp scrub list does **not** include `MOBILEGL_PIPE_*`, so the two processes can resolve different + masks from their own environments. Owner `hs`. +- **P6 maps no segment read-only.** `ShmSegment.h:62-63`'s stated design — the peer gets + `SEG_CMD`/`SEG_STAGE` read-only on the server side — **cannot be implemented as written**, and the + comment is corrected rather than left to mislead. Owner `lk`. +- **`DualBlockScenario`'s `serverIdentity != nullptr`** is an in-process fact, and G2/G14 requires + the spawn lane to keep the case name. Its assertions are **re-homed**, not weakened: `DualBlockPeek` + becomes a control-plane query answered by the server process. The declared fallback — assert + `MGPipeBlocksAreDistinct()` on the client side and carry the identity half by §3.4's server-side + Fatal — must be **declared here** if taken, not discovered in review. Owner `t6`. diff --git a/MobileGL/MG_Remote/Transport/ILink.h b/MobileGL/MG_Remote/Transport/ILink.h new file mode 100644 index 00000000..02af579e --- /dev/null +++ b/MobileGL/MG_Remote/Transport/ILink.h @@ -0,0 +1,277 @@ +// MobileGL - MobileGL/MG_Remote/Transport/ILink.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The 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 +// ; 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. + +#pragma once + +#include "../Protocol/mg_protocol_base.h" + +#include +#include +#include + +namespace MobileGL::MG_Remote::Transport { + + class Doorbell; + + // Which end of the link this instance is. Deliberately NOT ITransport's + // TransportRole: that enum's InProcess value names a control-plane + // hand-off, and a data plane's two ends are producer and consumer whatever + // the control plane did. + enum class TransportRoleTag : std::uint32_t { + ClientProducer = 1, + ServerConsumer = 2, + }; + + // ---- 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:71-77), `>=` + // 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 SIX AND NOT THE WHOLE PAGE. RingControl's watermark cache line + // today holds {appliedSeq, submittedSeq, retiredSeq, completedFrameSerial, + // presentAckSerial}, and submittedSeq is PRODUCER-written (Ring.h:50, + // "Advanced by the PRODUCER after it publishes") while the other four are + // consumer-written - so the line has MIXED WRITERS and no single-writer + // block can be aliased over it as it stands. eventRingFull and + // eventDropped, which every park predicate on both sides must also see, + // sit a full cache line away in the doorbell/generation group. lk's regroup + // moves submittedSeq out to the producer's own line and promotes the two + // event flags in, so THIS struct becomes a named member of RingControl with + // per-field offsetof static_asserts. Free today, because both peers are the + // same binary and the fingerprint enforces it; a wire break the day a + // second build exists, which is why it is booked now rather than later. + struct LinkProgress { + std::atomic appliedSeq; // records applied + std::atomic retiredSeq; // GPU finished + std::atomic completedFrameSerial; // releases *RetiredTail / SEG_ADOPT + std::atomic presentAckSerial; // returns present credit + std::atomic eventRingFull; // SEG_EVENT full, apply stopped + std::atomic eventDropped; // dropped lossy events + }; + + // ---- the producer's handle ---------------------------------------------- + // + // POD state, taken ONCE at attach and used non-virtually thereafter. The + // reserve/commit algorithm is NOT inlined here (see the header comment): + // ShmLink keeps today's out-of-line body, StreamLink writes its own. + // `Base` is null on a link that has no mapping, and a link that publishes + // that shape must answer every span through ResolveSpan. + struct LinkArena { + void* Base = nullptr; + std::uint64_t Capacity = 0; + std::uint64_t Mask = 0; + std::uint64_t LocalHead = 0; + }; + + // The consumer's mirror of the same. + struct LinkCursor { + const void* Base = nullptr; + std::uint64_t Capacity = 0; + std::uint64_t Mask = 0; + std::uint64_t LocalTail = 0; + }; + + // A run of bulk bytes named on the wire. On ShmLink `Offset` indexes the + // shared SEG_STAGE mapping; on StreamLink it indexes the trailing staged + // region of the batch that carried the record. MGPBlobRef's spelling and + // every honesty check over it (R-2) are unchanged either way - which is the + // property that lets the whole record catalogue cross a boundary with no + // mapping without a single record layout changing. + struct LinkSpan { + std::uint64_t Offset = 0; + std::uint64_t Size = 0; + }; + + // Which segment a span or a view belongs to. Mirrors SegmentKind's wire + // values; declared here so ILink does not pull the protocol headers in. + enum class LinkSegment : std::uint32_t { + Cmd = 1, + Stage = 2, + Reply = 3, + Event = 4, + }; + + // What a link can do. A caller may not infer any of these from the + // transport's role, and a link may not answer "sometimes" - rule G's + // second clause is that a transport DECLARES what it supports and may not + // invent a third answer. + struct LinkCapabilities { + bool SharedMapping = false; // peers see one set of bytes; no copy on read + bool DescriptorPassing = false; + bool PublishIsDelivery = false; // false => Flush() is mandatory before any wait + std::uint64_t MaxRecordBytes = 0; + std::uint64_t MaxReplyBytes = 0; + }; + + // ------------------------------------------------------------------------- + // The seam itself. + // + // Nineteen entry points, every one taken at attach, at a park boundary, or + // once per control operation. None on the hot path. + // ------------------------------------------------------------------------- + class ILink { + public: + virtual ~ILink() = default; + + ILink(const ILink&) = delete; + ILink& operator=(const ILink&) = delete; + + // ---- setup ---------------------------------------------------------- + + virtual LinkCapabilities Capabilities() const = 0; + + // The watermark block. Taken once; every wait predicate reads through + // the returned pointer with no further virtual dispatch. Never null on + // an attached link. + virtual LinkProgress* Progress() = 0; + + // The command-record arena (producer side) and cursor (consumer side). + // Exactly one of the two is valid on a given peer. + virtual LinkArena* RecordArena() = 0; + virtual LinkCursor* RecordCursor() = 0; + + // ---- bulk bytes ----------------------------------------------------- + + // Reserve `bytes` of staging and return where the record should name + // it. MOBILEGL_ERR_RING_OVERRUN when the request cannot be satisfied at + // any occupancy; the caller's existing back-pressure path is unchanged. + virtual MobileGLResult StageBytes(std::uint64_t bytes, LinkSpan* outSpan, + void** outWritePtr) = 0; + + // Resolve a span the peer named. On a shared mapping this is pointer + // arithmetic over the peer's segment; on a stream it points into the + // reassembly buffer of the batch that carried the record, and the + // lifetime rule ("valid until retiredSeq passes the record") is + // restated against that buffer rather than against the mapping. + // + // THIS IS THE METHOD WRITING StreamLink REVEALED. The brief's list - + // progress transmission, send window, flush - did not name it, and + // without it a stream link has no way to answer the eleven + // ResolveOrFatal call sites in the decoder. + virtual MobileGLResult ResolveSpan(LinkSegment segment, LinkSpan span, + const void** outPtr) = 0; + + // ---- replies -------------------------------------------------------- + + virtual MobileGLResult PostReply(std::uint64_t seq, std::int32_t status, + const void* payload, std::uint64_t size) = 0; + virtual MobileGLResult ReadReply(std::uint64_t seq, std::int32_t* outStatus, + const void** outPayload, std::uint64_t* outSize) = 0; + + // ---- the reverse channel -------------------------------------------- + + virtual LinkArena* EventArena() = 0; + virtual LinkCursor* EventCursor() = 0; + + // ---- wakeups -------------------------------------------------------- + // + // THESE TWO RETIRE CONTRACT-P5 §3.9. That ruling kept the doorbell + // accessors on the session "so that this stays one switch in one file + // rather than two virtuals every transport has to invent a home for" + // (ServerSession.cpp:709-724) - and the switch it produced aborts for + // every role but InProcess, so a spawn apply thread dies in + // ConsumerDoorbell before it pops its first record. The seam is the + // home the ruling said did not exist. lk deletes both accessors rather + // than growing them a socket arm. + virtual Doorbell& ConsumerBell() = 0; + virtual Doorbell& ProducerBell() = 0; + + // ---- publication ----------------------------------------------------- + + // Make everything published so far visible to the peer. On a shared + // mapping publish IS delivery and this is a no-op; on a stream it is a + // write(), and CONTRACT-P6.md requires every transition into a wait to + // call it first, or a kWaitReply record sitting in a send buffer while + // the client parks is a hang rather than a slowdown. + virtual MobileGLResult Flush() = 0; + + // Push the local progress block to the peer. A no-op on a shared + // mapping, where a release-store IS the transmission. Named separately + // from Flush because the two have different triggers: Flush is about + // records the peer has not seen, this is about watermarks the peer is + // waiting on. The second method writing StreamLink revealed. + virtual MobileGLResult FlushProgress() = 0; + + // ---- lifecycle ------------------------------------------------------- + + virtual bool Attached() const = 0; + virtual void Detach() = 0; + virtual TransportRoleTag Role() const = 0; + + protected: + ILink() = default; + }; + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/StreamLink.h b/MobileGL/MG_Remote/Transport/StreamLink.h new file mode 100644 index 00000000..19d53ecb --- /dev/null +++ b/MobileGL/MG_Remote/Transport/StreamLink.h @@ -0,0 +1,102 @@ +// MobileGL - MobileGL/MG_Remote/Transport/StreamLink.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// ILink's second implementation, DECLARED BY c6 AND IMPLEMENTED BY NOBODY. +// Authority: CONTRACT-P6.md §8.4. Its phase is P6.5. +// +// WHY A HEADER FOR SOMETHING THAT DOES NOT EXIST. ILink's adequacy - "can this +// seam name what a byte stream needs?" - is the one claim c6 makes that an +// argument cannot settle and a compile can. If ILink cannot express progress +// transmission, a send window, a flush, span resolution without a mapping, and +// replies that are messages rather than slots, then StreamLink cannot be +// declared against it, and the build says so. That is c6's red-once: delete +// one ILink method this class declares and the build goes red. +// +// It has already earned its keep. Writing it forced THREE methods that the +// original brief's list - progress transmission, send window, flush - did not +// name: +// +// ResolveSpan - a stream has no mapping to do pointer arithmetic over, so +// the eleven ResolveOrFatal sites in the decoder need the +// link to answer, not a segment table. Without it the seam +// was unimplementable for the case it exists for. +// FlushProgress - separate from Flush because the triggers differ: Flush is +// about records the peer has not seen, FlushProgress is +// about watermarks the peer is WAITING on. On a shared page +// a release-store is both; on a stream, conflating them +// either sends too much or hangs a waiter. +// PostReply / - SEG_REPLY carries no message today: the server memcpys +// ReadReply into a slot addressed `seq % slotCount` and the client +// reads it after observing appliedSeq. A stream turns that +// into a real message carrying its own seq, which DELETES +// the slot pool, the modulus addressing and the stamp +// self-check - so the seam has to own the operation rather +// than hand out the pool. +// +// WHAT P6.5 STILL HAS TO SETTLE, recorded here so it is not rediscovered: +// - SEG_STAGE's three ring cursors are DELIBERATELY DEAD (Ring.h:105-129, +// pinned by a test). Staging is an encoder-local linear allocator that +// reclaims on retiredSeq. A send window either revives them COMPLETELY or +// does not use them; the header names the middle state as a guaranteed +// hang rather than a slow path. +// - appliedSeq is excluded BY NAME from lazy publication (Ring.h:71-77). +// P6.5 AMENDS that rule with a flush-on-idle discipline; it does not infer +// around it. +// - one dedicated reader thread per direction per process, or the event-ring +// flow-control deadlock returns as write() blocked against write(). + +#pragma once + +#include "ILink.h" + +namespace MobileGL::MG_Remote::Transport { + + // Every entry point is a named refusal. There is no partial implementation + // and there may not be one: a half-wired stream link is the shape Ring.h + // warns about, where FreeBytes() falls to zero on the first lap and never + // recovers. + // + // The refusals live in StreamLink.cpp (package P6.5) rather than inline, + // for the reason ILink.h gives: no header under Transport/ pulls the logger + // in, and this one does not start. + class StreamLink final : public ILink { + public: + StreamLink(); + ~StreamLink() override; + + LinkCapabilities Capabilities() const override; + + LinkProgress* Progress() override; + LinkArena* RecordArena() override; + LinkCursor* RecordCursor() override; + + MobileGLResult StageBytes(std::uint64_t bytes, LinkSpan* outSpan, + void** outWritePtr) override; + MobileGLResult ResolveSpan(LinkSegment segment, LinkSpan span, + const void** outPtr) override; + + MobileGLResult PostReply(std::uint64_t seq, std::int32_t status, + const void* payload, std::uint64_t size) override; + MobileGLResult ReadReply(std::uint64_t seq, std::int32_t* outStatus, + const void** outPayload, std::uint64_t* outSize) override; + + LinkArena* EventArena() override; + LinkCursor* EventCursor() override; + + Doorbell& ConsumerBell() override; + Doorbell& ProducerBell() override; + + MobileGLResult Flush() override; + MobileGLResult FlushProgress() override; + + bool Attached() const override; + void Detach() override; + TransportRoleTag Role() const override; + }; + +} // namespace MobileGL::MG_Remote::Transport diff --git a/docs/Disaggregated/CURRENT_STAGE_PROGRESS.md b/docs/Disaggregated/CURRENT_STAGE_PROGRESS.md index 66d5c271..63791314 100644 --- a/docs/Disaggregated/CURRENT_STAGE_PROGRESS.md +++ b/docs/Disaggregated/CURRENT_STAGE_PROGRESS.md @@ -13,7 +13,7 @@ | **P5d** `inproc` 性能专项 | **已收官(2026-09-18,三轮)** | `cb06538c`、`56a77348`、`1f8de61b`;报告 [`P5D-INPROC-PERFORMANCE.md`](P5D-INPROC-PERFORMANCE.md);`MEASUREMENTS.md` §9 | | **P5e** 退役 Espryt draw path 的 lockstep | **已收官(2026-09-19)**,附一条具名未决(E1 对照,ID-122) | 契约 `MobileGL/MG_Remote/CONTRACT-P5E.md`;计划 `~/w7/notes/p5e/BRIEF-P5E.md`、裁定 `~/w7/notes/p5e/INTEGRATOR-DECISIONS-P5E.md`(**ID-80..136**);**十二个包已全部落地合并**(§2.7),`kMGPipeP5eRunAheadReady` 与 `kMGPipeP5eClientWaitRuleLanded` 均已翻。strict 车道硬绿 179/179、`integration-gpu` 1357/1357、三个构建 flavour 全绿;设备上 VD32 已与 monolith 齐平。报告 [`P5E-RUNAHEAD.md`](P5E-RUNAHEAD.md),未完成项见 §5 | | **P5f** 一切状态上 wire | **已收官(2026-09-20)** | f0 / f1 / fc / fe / fm / fs / fr / fv 及分类收口均已完成;零 BARRIER_PULLED、两份 marker 空表、逐帧 rsp=0。主机全门、Claude 异族终审两项修复及 red-once、Redmi 六 clean-boot 臂通过。见 [`close-report`](notes/p5f/close-report.md)、[`close-review`](notes/p5f/close-review.md)、[`device-report`](notes/p5f/device-report.md) | -| **P6** spawn transport | **a6 已收官(2026-09-21)**,c6 开工中 | 计划 [`P6-SPAWN-PLAN.md`](P6-SPAWN-PLAN.md)、草稿 [`P6-CONTRACT-DRAFT.md`](P6-CONTRACT-DRAFT.md);a6 见 [`a6-audit-v1.md`](notes/p6/a6-audit-v1.md) 与 [`a6-link-experiment.md`](notes/p6/a6-link-experiment.md)。传输原语与 P5f 值控制帧、静态世代/角色隔离已在树上;待做的是进程装配、socket 传输、握手/EOF/device-lost 等,不再重做已落地的控制帧与静态量 | +| **P6** spawn transport | **a6、c6 已收官(2026-09-21)**;下一个包 `lk`(数据面缝的实现) | 计划 [`P6-SPAWN-PLAN.md`](P6-SPAWN-PLAN.md)、草稿 [`P6-CONTRACT-DRAFT.md`](P6-CONTRACT-DRAFT.md);a6 见 [`a6-audit-v1.md`](notes/p6/a6-audit-v1.md) 与 [`a6-link-experiment.md`](notes/p6/a6-link-experiment.md)。传输原语与 P5f 值控制帧、静态世代/角色隔离已在树上;待做的是进程装配、socket 传输、握手/EOF/device-lost 等,不再重做已落地的控制帧与静态量 | ## 2. P5f 最终验收 diff --git a/docs/Disaggregated/ROADMAP.md b/docs/Disaggregated/ROADMAP.md index 5b5a6f01..78b0afe7 100644 --- a/docs/Disaggregated/ROADMAP.md +++ b/docs/Disaggregated/ROADMAP.md @@ -32,7 +32,7 @@ | **P5d** `inproc` 性能专项 | ✅ `cb06538c`、`56a77348`、`1f8de61b` | 一二轮:persistent-map 推送三级臂(mprotect 脏页追踪 + 哈希抑制 + 绑定过滤 `PushDrawConsumers`)、barrier 批处理 `MOBILEGL_IPC_BATCH_WAITS` + 延期销毁队列、自旋时钟 64 合 1、零页注册 + shadow 页对齐分配。三轮(按符号化 profile 逐项切,四包 + 一份 lockstep 可行性研究):apply 空转轮询零锁(控制邮箱原子影子位)、`Doorbell::Wait` 校准自旋稳态不读时钟、wait/park 计数进 stats;`OnApplyThread()` 内联为线程指针比较、三个 scope 深度计数器退掉 `thread_local`;shadow 分配尺寸补页、追踪范围向外对齐、边块哈希消失、脏页排空修复;image unit 高水位、残余填充 memo、server stamp 常量表;`generate_mipmap` 不再免等(apply 读残余输入)、death 测试围栏 | 三轮设备(Redmi,CPU 定频):inproc **64.3 → 103-106 fps**(p50),client 线程 CPU 15.0 → 9.2 ms/帧、apply 12.4 → 7.0-7.4;monolith 同场景 115(120 Hz vsync 封顶的运行)/ 206(一次未封顶,渲染线程 5.0 ms/帧 大核);配对比较以逐线程 CPU/帧为主,见报告"数字"节;unit 2203/2203、`integration-split` 111/111(两条曾 flaky 的用例复跑 6/6);双生成器、include 闭包、dirty-surface、doc 引用绿。未完成:R-1 序列化(研究结论:留给 P3b/P4b 后的 P11 `gPipeInputs` 版本化)、线程放置(内核忽略亲和)、`SetHashSuppressor` 哈希等小项。报告:[`P5D-INPROC-PERFORMANCE.md`](P5D-INPROC-PERFORMANCE.md) | P5b | | **P5e** 退役 Espryt draw path 的 lockstep | ✅ **已收官(2026-09-19)**,附一条具名未决:E1 对照需按 ID-114 重新定义 | 契约 `MG_Remote/CONTRACT-P5E.md`(规则 F:**未设障**记录的 apply 不得读任何 client 内存;ID-84 限定它只约束未设障记录);裁定 **ID-80..136**(`~/w7/notes/p5e/INTEGRATOR-DECISIONS-P5E.md`)。**十二个包,三波**:wave 1 **c0e**(契约 + 线上行:`PipeCalls.def` 第五列 `WaitClass`、`set_program_bindings` opcode 80、`kCapRunAheadApply` 位 10 仅 DirectGLES 臂、`kDrawClientArrays`、子系统位 13、两个旋钮)→ **id**(registry 按 `{slot, gen}` 重键、by-handle resolver、分配器守卫);wave 2 **vi / sb / pg / tx2 / fb** 五个 per-draw 家族 ∥ **ra**(等待规则、present credit、`gPipeInputs` 变 server 角色内存),加 **fix1**(设备发现的 monolith 臂回归,ID-107);wave 3 **pa**(program 家族离开前端)、**mv**(multi-draw + 五个档位的门禁条目)、**gl**(门禁机制本身 + 两处潜伏崩溃)、**ra2**(翻开关后暴露的竞态)。**`kMGPipeP5eRunAheadReady` 与 `kMGPipeP5eClientWaitRuleLanded` 均已翻**。最大的那次迁移(逐 draw 的 `GetProgramForDraw`)**没有改动任何线上结构**:记录早就带着逐 link 的 `ProgramArchive`,缺的是仍去问前端对象的读者。报告 [`P5E-RUNAHEAD.md`](P5E-RUNAHEAD.md) | 出口门已逐条跑过(BRIEF-P5E §3,2026-09-19 在集成树上,run-ahead 已武装):**item 1** unit 在 strict 两臂均 **2256/2256**;**item 2/3** `integration-split` **179/179**、逐条普查零 `Fatal{`、`Admitted{` 恰为八行;**item 4(a)** `RUN_AHEAD=0` 作为配对对照 **179/179 绿**;**item 4(b)** `VERB_BARRIER=0` **变红**(车道 62/179);**item 4(c)** Magma 逐字记下 `run-ahead requested, server does not publish kCapRunAheadApply - running lockstep`,而 Espryt 对照记下 `run-ahead ARMED`;**item 5** 在集成树上重跑 red-once(ID-121 重排后的形式):倒掉 pa 的臂得 `GetProgramForDraw@DrawArrays`、倒掉 mv 的臂得 `GetBoundVertexArray@DrawArrays`,**恰好各一个具名对**,还原后各自转绿;**item 7** `MOBILEGL_IPC_AUDIT=1` **179/179**。**唯一未过的是 E1 对照脚本本身**(ID-122):已修掉它两个遮蔽性缺陷(选中了一个在本车道永远跑不起来的阳性对照用例;ctest 与日志助手对“谁被选中”意见不一致),但它索要的 `Fatal{BarrierViolation}` 在整次运行里 **出现 0 次**:`BATCH_WAITS` 默认值 1 下那个重叠不发生,而 run-ahead 武装后 `VERB_BARRIER=0` 还会顺手关掉 run-ahead(ID-114),它产生的那条臂**既不是 lockstep 也不是 run-ahead**。E1 需要重新定义它要证伪什么,不是再调阈值;在想清楚之前不动它的计数规则,否则只是换一种伪绿。G1 仍由 CI 断言(ID-123) | P5d | | **P5f** 一切状态上 wire | ✅ **已收官(2026-09-20)**;验收代码 Windows `c42577a4` / WSL `cfca93c7885f` | f0 普查、f1 双块、fc 控制帧、fe Espryt 字段、fm Magma record consumers、fs 静态世代/角色及 liveness、fr registry、fv 反向回调均已合并。字段归属 **41 RECORD_SUPPLIED / 6 APPLIER_DERIVED / 0 BARRIER_PULLED / 16 FATAL**;strict / dualblock 预期 marker 集合均空。控制帧与静态量工作已完成,不再列为 P6 待实现项 | unit **2310 = 2300 PASS + 10 skip**;GPU **1377** 零失败;ordinary / strict split **180 = 176 PASS + 4 skip**;双块 **212 = 206 PASS + 6 精确具名 skip**;双后端 RSP 门 **2 PASS / 0 skip**、逐帧 rsp=0。G1 `.text` 10806051、symbols 27815 恒等且 0/0/0/0;G2 3016 同名;G14 3646→3744 无删名。逐包与收官修复均有 red-once;Claude 异族终审 P1/P2 已修;Redmi 六 clean-boot 臂 **60 PASS / 6 skip / 0 failed**、36 个窗口 rsp=0。详见 [`close-report`](notes/p5f/close-report.md) | -| **P6** spawn transport | **a6 已收官(2026-09-21)**,c6 开工中 | `SocketTransport`(socketpair + fork/execve,envp 剔除 + 强制 monolith 双保险);`ServerMain`;`MOBILEGL_IPC_SERVER_PATH` + `dladdr` 兜底;有界重试握手;EOF 即时退出;device-lost latch。消费 P5f 已落地的值控制帧与 server-owned 状态,不重做 EGL 帧化或静态世代隔离。依赖序见 [`P6-SPAWN-PLAN.md`](P6-SPAWN-PLAN.md):**a6**(核验进程装配/链接边界)→ **c6**(契约)→ **so / sm / cp / st / t6**。契约草稿 [`P6-CONTRACT-DRAFT.md`](P6-CONTRACT-DRAFT.md)。**a6 产出**:[`a6-audit-v1.md`](notes/p6/a6-audit-v1.md)(198 行,七项各带对抗复审)、[`a6-link-experiment.md`](notes/p6/a6-link-experiment.md)(**server 不能不链 `MG_Impl`**:184 个符号,债主 P7 101 / P3b-P4b 14 / 共有 60 / P6 自己 6);终局重审见 [`P6-ENDSTATE-REVIEW.md`](P6-ENDSTATE-REVIEW.md)。a6 推翻计划四处陈述:`spawn` 今天不是可观察状态、`cp` 不止传输、device-lost 闩锁不存在、"server 不链 MG_Impl" 为假。边界仍为 pbuffer / surfaceless / 离屏;真窗口到达归 P12。需区分不等待的 client 与已死亡的 server,不以超时阈值代替 device-lost 判定 | P5b 的完整渲染路径在 `spawn` 下绿;进程树只多一个子进程;`HeadlessGL` fork 预检无孤儿;OpenRA 在 Adreno 830 上 split SSIM ≥ 0.99;`integration-spawn` 与 `integration-split` 名集合一致;每条私有日志证明子进程 pid 与 `transport=spawn`;socket 门铃代价实际测量,性能只记录 | +| **P6** spawn transport | **a6、c6 已收官(2026-09-21)**;下一个包 `lk` | `SocketTransport`(socketpair + fork/execve,envp 剔除 + 强制 monolith 双保险);`ServerMain`;`MOBILEGL_IPC_SERVER_PATH` + `dladdr` 兜底;有界重试握手;EOF 即时退出;device-lost latch。消费 P5f 已落地的值控制帧与 server-owned 状态,不重做 EGL 帧化或静态世代隔离。依赖序见 [`P6-SPAWN-PLAN.md`](P6-SPAWN-PLAN.md):**a6**(核验进程装配/链接边界)→ **c6**(契约)→ **so / sm / cp / st / t6**。契约草稿 [`P6-CONTRACT-DRAFT.md`](P6-CONTRACT-DRAFT.md)。**a6 产出**:[`a6-audit-v1.md`](notes/p6/a6-audit-v1.md)(198 行,七项各带对抗复审)、[`a6-link-experiment.md`](notes/p6/a6-link-experiment.md)(**server 不能不链 `MG_Impl`**:184 个符号,债主 P7 101 / P3b-P4b 14 / 共有 60 / P6 自己 6);终局重审见 [`P6-ENDSTATE-REVIEW.md`](P6-ENDSTATE-REVIEW.md)。**c6 产出**:规范契约 [`MG_Remote/CONTRACT-P6.md`](../../MobileGL/MG_Remote/CONTRACT-P6.md)(21 条裁定,取代草稿)+ 数据面缝的惰性声明 `Transport/ILink.h` 与 `Transport/StreamLink.h`;零 TU 依赖它们、零构建规则、pull 与 split 两侧 `.text` 与符号集逐字不变。c6 指定但留给 `hs` 落地的 schema 行见契约 §2。a6 推翻计划四处陈述:`spawn` 今天不是可观察状态、`cp` 不止传输、device-lost 闩锁不存在、"server 不链 MG_Impl" 为假。边界仍为 pbuffer / surfaceless / 离屏;真窗口到达归 P12。需区分不等待的 client 与已死亡的 server,不以超时阈值代替 device-lost 判定 | P5b 的完整渲染路径在 `spawn` 下绿;进程树只多一个子进程;`HeadlessGL` fork 预检无孤儿;OpenRA 在 Adreno 830 上 split SSIM ≥ 0.99;`integration-spawn` 与 `integration-split` 名集合一致;每条私有日志证明子进程 pid 与 `transport=spawn`;socket 门铃代价实际测量,性能只记录 | | **P3b / P4b** 深化(Espryt) | 待排 | 按存储属主键控的发射游标与 view 索引重映射;`g_fboTextureSyncList`;`ResolvedTextureBindingMemo` / `SamplerPassMemo` / image sweep / program registry 重键;XFB scatter 搬到 client;删 fragColor 重推导 workaround 与 `g_broadcastMemo*`;raw-depth-fetch sampler 的 monolith 清理(transport 原生臂已由 P5f fs 落地);回读 / pack state;P4a 记下的 R-3 / R-4 / R-5 / R-7 / R-10 / R-11;`ProgramArtifacts.h` 的 NDK 尺寸钉 | 纹理 / program 场景;CTS `texture_*` / `shader_image_*` / `packed_pixels` 在 0.5 pp 内;每一个 Iris trace;`TextureUploadShapeScenario` 升级成门 | | **P7** DirectVulkan(Magma)全量迁移 | 部分已提前落地,阶段未收官 | `38919d45` 已补实际游戏的 server buffer consumers 和 Android 旋转 blit([报告](notes/p5f/magma-inproc-fix.md));`194382c9` 又完成 Magma run-ahead、GPU 延迟退休与 VAO 身份碰撞修复([报告](notes/p5f/magma-runahead.md));其余工作:其余子系统:`SetupDrawSnapshot` 探测字段塌成 dirty mask;占位纹理原生化;具名 UBO host payload(D-B8);内部 shader 烘焙;`VertexInputStateFactory` 内容寻址 CSO;D18 容器纪律保留 | 集成 + trace 在 Magma 的 push 与 split 下全绿;verify 零分歧;`nm -D libMobileGLServer.so \| grep glslang` 为空;CTS 0.5 pp 内。**再基线检查点:中点完成子系统 < 40% 立即重定基线** | | **P8** emulation 下放 + 索引宿主镜像 + 协议广度 | 待排 | `MG_Impl/Pipe/HostResolve.cpp`(client 数组范围、最大索引扫描、`*IndirectCount` 解析,逐站点 reconcile);`Server/IndexHostMirror`;CopyImage 镜像搬到 client;viewport-array 回放验证;`generate_mipmap` 计划 + CPU 回退纹素;大 blob carrier(开放问题 11);无 present fence tick;`kCapDriverOrderedXfbCapture` | `'^DirectGLES\.Split\.'` 与 `'^DirectGLES\.'` 逐名相同;trace split 双后端 SSIM ≥ 0.99 含两个 `coherent_as_flush` fixture;`ClientArrayAfterComputeWriteScenario`;`create-indirect` 上 `roundtrips-per-frame` 读零;`index-mirror-bytes` 逐用例发布 |