Files
MobileGL/docs/archive/contracts/CONTRACT-P6.md
T

758 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 @ c25a7760`. 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`](../notes/p6/a6-audit-v1.md)
(198 rows, seven items, each adversarially re-resolved) and
[`a6-link-experiment.md`](../notes/p6/a6-link-experiment.md) (the 184 symbols).
It supersedes [`P6-CONTRACT-DRAFT.md`](../notes/p6/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`](../notes/p6/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` |
> **asio was considered for the byte stream and declined (2026-09-21).** It is already vendored
> and on the include path, so the question is a fair one. But spawn needs five platform
> mechanisms and asio covers one: its own `socketpair` returns `operation_not_supported` on
> Windows (`3rdparty/asio/include/asio/detail/impl/socket_ops.ipp:683`), it has no descriptor
> passing at all, segment delivery is POSIX-only by design, and there is no `fork`. So adopting
> it would not make Windows work; it would only put asio headers inside `Transport/`, which
> `ITransport.h` keeps dependency-light on purpose. **Revisit at P6.5**, where
> `asio::generic::stream_protocol` carries a raw (family, type, protocol) triple and therefore
> gives AF_VSOCK from the same implementation (2026-09-22: the end state's link is TCP, not
> AF_VSOCK - §10.3 - and the argument is the same for `asio::ip::tcp`) - and where `ITransport` lets an `AsioTransport`
> land beside `SocketTransport` without touching a caller.
| `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` → **not landed** (verified 2026-09-22: `protocol.fbs` has no `LinkTerms`); re-homed to P6.5 `nd`, §10.3 |
| `Refuse{code, detail, peer values}` | Appended `CtrlMsg` union tag. A handshake disagreement is an **answer**, not an abort (§5.1). | — | both sides | `hs` → **not landed** (no `Refuse` in `union CtrlMsg`); re-homed to P6.5 `wf`, §10.3 |
| `wireFingerprint` / `buildFingerprint` | The single `abiFingerprint` splits. §4. | mismatch → `Refuse`, never `Fatal` | the handshake | `hs` → **partially landed** (explicit build stamp, `abiMajor`/`abiMinor` checked, real pids; the split itself and the `PipeFields.def` layout digest are not); re-homed to P6.5 `wf`, §10.3 |
| `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.
> **LANDED, in three steps.** (1) `FatalFamilies.def` + `FatalFamily.h` generate `MGFatalFamily`,
> its total projection `FatalCodeForFamily` and `FatalFamilyName`; 32 rows, tested. (2) `SessionFail`
> (`FatalFunnel.{h,cpp}`) is the funnel: the ~90 scattered `MGLOG_F("...Fatal{...}"); abort();` pairs
> under `MG_Remote/` become one call each, the message string passed **verbatim** so every line and
> grep is byte-identical, the family enum riding alongside for the coarse code. `SessionFaultCount()`
> is the telemetry point. The census now also requires every `SessionFail`/`WireLogFatal` call's
> string to carry a `Fatal{` word, and its baseline drops from 93 bare aborts to 4 (both funnels).
> (3) `table Fatal` gains `family: string`; `SessionFail` publishes the frame on whichever session
> control plane is active before aborting, and the client's control-reply loop recognizes it, names
> the family and latches device-lost. **seq/op are NOT threaded** through the 90 sites — the message
> already names the op — so the frame carries `code`, `family` and `message`, not a field always 0.
> A `kill -9` bypasses `abort()` and is caught by `dl`'s hangup witness instead; the self-detected
> fatal is the one that publishes.
**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::PeerHungUp()` — D5c amends "`Dead()`" here, and §5.4's block says why — **never from a
timeout**. `MOBILEGL_IPC_RESPAWN=1` is a named refusal until some stage implements the re-push.
**LANDED.** `ClientSession::DeviceLost()` / `LatchDeviceLost()`, armed at the verb barrier, at the
forced run-ahead wait and at the control-reply wait, and read by `GetGraphicsResetStatus`, which
now answers **`GL_UNKNOWN_CONTEXT_RESET`**. Not `GUILTY` or `INNOCENT`: those assign blame, and the
server died for a reason that never crossed the wire — it could as easily have been a driver fault
as this client's draw. The consult and its include sit wholly inside `MOBILEGL_BUILD_DISAGGREGATED`;
G1 re-measured after it, **0 symbol diff and `.text` unchanged at `0xa52203`**. That measurement
also caught an unrelated 16-byte drift the guards did not: P6's O_APPEND log-sink fix was compiled
into the pull build too, and is now guarded for the same reason.
### 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, "<op>@<seq>"}`. (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). (P7, p7/spawnhang: "silent" is now literal — a server whose
apply thread is running the op says so every 250 ms with `SurfaceProgress`, and each report restarts
the client's bound, up to 120 s; a server that never took the op, or is frozen, sends none.)
> **D5c — the client's self-bell CANNOT witness the server's death, and `sm` is why.** Measured, not
> predicted: `minecraft-1.21.4-fabric-iris-iterationrp-in-world` kills the *server* process outright
> on this machine (llvmpipe: `LLVM ERROR: Cannot select: intrinsic %llvm.x86.vcvtps2ph.256`, SIGABRT
> at call 159429), and the client did **not** take the `SessionWait::ShutDown` path that already
> exists and is already correct. It sat out the full 120 s and reported
> `Fatal{BarrierTimeout, "ResourceRespecify"}` — the wrong diagnosis, at 120 s a case, for a server
> that had been gone since the first second. Under monolith the same trace fails in 3 s.
>
> The cause is structural and is the price of the two-fd bell: the client parks on `clientBell[0]`
> (slot 5) and rings *itself* through `clientBell[1]` (slot 6), 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 can never hang up, no matter what happens to the peer. A bell that can wake itself is
> a bell that cannot hear a death.
>
> **`dl` therefore needs a witness descriptor whose far end only the SERVER holds**, polled beside
> the park fd and never read from, whose `POLLHUP`/EOF latches `Dead()`. The control socket is
> already exactly that and needs no new descriptor. The fix does not belong to `sm`: holding both
> ends is required for self-notification and is not the defect — assuming one descriptor could
> answer both questions is.
>
> **LANDED.** `SocketDoorbell::SetDeathWitness(fd)`; the client hands it
> `SocketTransport::StreamFd()`. The witness enters the poll set with `events = POLLRDHUP` **and
> nothing else**, which is what lets it watch a socket it must never read: `POLLHUP`/`POLLERR`/
> `POLLNVAL` arrive in `revents` unrequested, so the hangup is seen while `POLLIN` never is — a
> control reply queued on that descriptor can neither wake the bell nor be consumed from under
> `SocketTransport`'s reassembler. Measured: `kill -9` on the server is noticed in **0.01 s**
> against a 120 000 ms barrier, and with the witness removed the same test takes the full 2 s
> retry budget and fails naming this paragraph.
>
> **`Dead()` AND `PeerHungUp()` ARE TWO FACTS, and only the second may arm the latch.** `Park`
> sets `m_dead` for `POLLERR`, `POLLNVAL` and unrecognised `revents` as well — each a fault in
> *this* process's descriptor — and `CondVarDoorbell::Kill()` sets it on every orderly `Stop()`.
> A latch taken from `Dead()` would report a lost GPU for a local fd bug and a context reset for
> every clean exit. `ADeadBellIsNotAlwaysAHungUpPeer` is the red-once for the distinction;
> `AnOrderlyStopIsNotADeviceLoss` is **not** — it stays green under the collapse, because under
> spawn the self bell is a `SocketDoorbell` and `Stop()` never kills one. That was found by
> running the falsification rather than by reading the code.
---
## §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`'s "THE FIVE WATERMARKS" block) 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, beside `cmdHead`,
which is the other thing the producer writes; what is left — the four consumer-written watermarks —
becomes a named `LinkProgress` member with per-field `offsetof` static_asserts.
> **Correction, found while `lk` was measuring its own blast radius.** An earlier revision of this
> section, and of `ILink.h`, also promoted `eventRingFull`/`eventDropped` into the watermark group
> and called the result single-writer. **Both were wrong.** `eventRingFull` has two writers: the
> server latches it with `store(1)` when `SEG_EVENT` fills (`Transport/EventRing.h:140`) and the
> **client clears it with an `exchange(0)` on every drain** (`:200`). Promoting it 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 10⁵–10⁶ times per frame.
> That is new false sharing on the hottest line in the system, and it would land in exactly the
> numbers this package's gate has to hold constant. The two flags stay in the doorbell/generation
> group, where mixed writers already live beside `consumerParked`/`producerParked`, and cross the
> seam through a separate `EventFlags()` accessor. *Accessor*, not line: they share
> `serverEpoch`'s line, which is where mixed writers already live.
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`'s "DEAD IN P5, DELIBERATELY" 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`'s "BATCHING MAY ONLY MAKE A WATERMARK LATE" — `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.
### 10.3 Amendments of 2026-09-22 — the end state re-ruled
1. **The end state is TCP across machines, OSes and architectures**, not an AVF pVM over
`AF_VSOCK`. `docs/Disaggregated/notes/p6/P6-ENDSTATE-REVIEW.md` carries the retraction at its head; its
§3 gains form **D**. Same-machine `spawn` (AF_UNIX + shared segments) stays as the local form.
2. **The transport stack has two independent axes.** The control plane (`ITransport`: `fork`
inherited fds / `unix:<path>` / `tcp://host:port`) and the data plane (`ILink`: `ShmLink` /
`StreamLink`) are chosen separately, negotiated in the handshake, and may be mixed — on one
machine, control over TCP with data over shared segments is a legal pair. Callers above
`Transport/` see `ITransport` + `ILink` and nothing else; branching on the link kind above that
line is a purity-gate failure (the `lk` grep gate, widened). Design: `ARCHITECTURE.md` §11.9.
3. **§8.2's "`ITransport` is not extended" is amended in one respect**: descriptor passing leaves
the control plane and becomes the shared-segment data plane's *delivery*. `ShmLink` brings its
own AF_UNIX aux rendezvous, named in `Welcome`, and `SCM_RIGHTS` travels on that and nowhere
else. Rule G is unchanged: a transport that declares no descriptor passing (TCP) passes none,
and the data plane that needs it must bring its own channel or not be selected. `auto`
selection is decided by a **successful delivery**, never by inspecting addresses, and a
fall-back to `Stream` is named in the log and the stats line.
4. **§2's three `hs` rows** (`LinkTerms`, `Refuse`, the fingerprint split) did not land in `hs`;
`protocol.fbs` carries neither `LinkTerms` nor a `Refuse` member of `CtrlMsg`. They are P6.5's
(`nd`, `wf`), and S6 in §9 moves with them. `CURRENT_STAGE_PROGRESS.md`'s earlier claim that
S6 had been run red is withdrawn.
5. **Fixed-width wire form is no longer negotiable-later.** Two different binaries from two
compilers are the end state, so the `PipeFields.def`-derived layout digest, the fixed-width
rewrite of `DynamicBackendParameters` / `MGPCaps` / the `RenderStateParameters` blob, and the
`buildFingerprint`-only-under-`Dial == Fork` policy are P6.5 `wf`, taken off P7.
6. **`Ph` gains pairing/authentication**: a `Hello` without a pairing token is refused on any
non-loopback listener. `Ph` precedes P12's non-loopback listen.
7. **"The 511 class-C entries" in §11** is the P5-joint census
(`docs/Disaggregated/notes/p6/census-classC.md`). At this head the `MGR_UNMIGRATED_*` lists are
empty; `SetSwapInterval` (P10) and `DeleteTransformFeedback` (P9) are the two that remain.
(2026-09-29: both are gone. P10 made the `SetSwapInterval` slot the server forwarder, so class C
is empty - `CONTRACT-P10.md` §2.)
---
## §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 (landed by P6.5 wf);
`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 two-axis transport stack — control × data, TCP-capable, cross-build — is P6.5** and is the
IPC track's next stage (§10.3). P9 builds its reply semantics on P6.5's message replies rather than
on a slot pool, and P11's T0/T1 are refused by name on a stream data plane (landed by P11 A1 at
the handshake: `Transport/AdoptTier.cpp`, `SettleAdoptTierAtHandshake`; CONTRACT-P11 §1).
**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`](../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 (landed, P7 wave 0 — ID-P7-7)**: `scripts/link_ratchet.py` recomputes this set from an
explicit per-object partition on every `build-linux-split` run and fails on any symbol absent from
`scripts/data/link_ratchet_baseline.txt` — which stands at **186** on `e8b2c4bd`, the 184 plus
P6.5's `Client::ClientSession::StartSpawned()` and `MG_State::GLState::ProgramArtifactsSchemaFingerprint()`,
with the three backend buckets reproducing a6's 101 / 14 / 60 exactly — while a symbol that
disappears is reported rather than failed, so progress never turns the build red
([`link-ratchet.md`](../notes/p7/link-ratchet.md)).
### 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`.