Files
MobileGL/docs/Disaggregated/notes/p5e/scout-S6.md
T
swung0x48 aba329cc53 [Docs] (Disaggregated): P6's plan and contract draft; the working notes move from a local directory into the repository
Two things land together: the plan and contract draft P6 needs before it starts, and the
working notes that until now existed on exactly one machine.

1. P6's plan and contract draft (neither started; not one line of code)

     docs/Disaggregated/P6-SPAWN-PLAN.md      the package plan
     docs/Disaggregated/P6-CONTRACT-DRAFT.md  the contract draft (moves to
                                              MG_Remote/CONTRACT-P6.md when c6 lands)

   The three findings that mattered, all written into the documents:

   a. P6 is SMALLER than its roadmap cell suggests. The transport primitives are already in
      the tree and already tested: SocketDoorbell including hangup detection and the two
      lost-wakeup fences (Doorbell.cpp:211-360, four cases in FdPassingTest), SCM_RIGHTS fd
      passing, ShmSegment::Adopt, and the SurfaceOp / WindowKind schema already defined in
      protocol.fbs. inproc uses a real second shm mapping rather than an alias, and
      SessionRings.h:22-28 says that was done so today is not the first time it runs. P6 is
      assembly plus a control plane plus a process, not "write a socket transport".

   b. The evidence for "P6 is only a transport swap" has EXPIRED. It was bought by P5c, whose
      audit found 59 direct memory accesses in a tree that already looked clean. P5e changed
      the tree again. So the first package, a6, is a read-only audit that writes no code, and
      every contract row that depends on it is marked PENDING a6 rather than decided in
      advance.

   c. P6 does not get a game running on the phone. ANativeWindow* is meaningless in the child,
      and protocol.fbs:186-196's own comment says Android transfers the window out of band -
      that band is P12. P6 lands pbuffer / surfaceless / offscreen, which is what HeadlessGL,
      the split-equivalent lane and trace replay need, and which is exactly what P6's own exit
      gate measures. A real window reaching the server is a named refusal. This gets its own
      section because "P6 is done" would otherwise read as "the game runs now".

   The contract adds rule G: a role may not name a process-local handle. It extends P5c's
   rule E (a role may not name the other role's memory) from memory to identity - EGLDisplay /
   EGLSurface / EGLContext / ANativeWindow* / fd number / pid / mapped address either cross as
   a token this contract defines or they do not cross, and a descriptor crosses only through
   SCM_RIGHTS, where the kernel renumbers it.

   What P5e left for P6 gets its own section. The latch semantics are already designed
   (ARCHITECTURE.md:504) and are copied unchanged. What is new is that after run-ahead the
   client no longer waits while the peer process can DIE: the client holds records that were
   published and will never be applied, and Android's freezer suspends the child without
   closing anything. So slow and dead must be distinguishable WITHOUT a timeout threshold.
   That is P6's only new design; everything else is carriage. It shares a predicate with E1's
   outstanding re-specification (ID-122), and the two should be designed together.

   Two gates carried over from P5e's lessons: G2 requires integration-spawn's test-name set to
   match integration-split's name for name, because a differing set means the lane is quietly
   running less; and the arm-proof gate requires every entry to record the child pid and
   transport=spawn in its OWN private log - ID-124 established that this kind of false green
   really happens, and a spawn lane that actually ran monolith would pass every other gate.
   S2's shape must be settled before it is written, and the document says why: E1 is the
   cautionary case.

2. The working notes move into the repository (docs/Disaggregated/notes/, 415 .md files)

   ROADMAP / CURRENT_STAGE_PROGRESS / MEASUREMENTS cite ruling numbers, audit line numbers and
   the provenance of gate figures from ~/w7/notes/, a directory that exists on one machine and
   is under no version control. A record cited hundreds of times that lives only locally is not
   a record, so it moves in whole, with its directory structure intact.

   Only .md moves. Scripts, run logs, census and gate result JSON, patches, APKs and nm dumps
   stay where they are: they are evidence DATA rather than documents - large, regenerated per
   run, and mostly carrying absolute paths and device serials that mean something only on the
   machine that produced them. The mapping and this split are written in
   docs/Disaggregated/notes/README.md.

   Two exceptions are recorded there too: P6's BRIEF has been promoted to a first-class
   document and keeps no copy under notes/, so the two cannot drift apart; and recovered/ holds
   material salvaged from failed workflow runs, which may duplicate the like-named phase
   directories and is kept because a few of those were the only complete version at the time.

   CI's citation lint takes docs/Disaggregated/*.md only (non-recursive), and notes/ is
   deliberately outside it: those notes' file:line citations were written against their own
   code heads, and resolving them against today's HEAD would fail wholesale - which is not an
   error, it is their correct state as a historical record. To check one, use the revision it
   declares (--rev).

3. Inconsistencies fixed in passing

   - README.md's status line still said P5e was in progress while the stage table already said
     closed.
   - CURRENT_STAGE_PROGRESS's next-step item 1 still listed the exit gate as pending, though
     (a) and (b) were run in the previous commit; rewritten to closed plus the one item that
     actually remains (E1).
   - ROADMAP's P6 row still had the pre-phase plan in three of its cells; it now carries the
     package order, the scope boundary and the two new gates.

Checked with check_doc_citations.py: 125 citations across docs/Disaggregated/*.md, 0 problems.
2026-09-18 23:31:39 -04:00

108 lines
25 KiB
Markdown

# S6 - transport mechanics of run-ahead (head `2fde7034`, paths under `MobileGL/`)
## 1. The exact sync points of the transport family today (live = a wait or a cross-role write that exists only because of the lockstep; rec = already safe)
| site | what it does | kind |
|---|---|---|
| `MG_Remote/Client/ClientSession.cpp:840-846` | `Fatal{BarrierViolation}` if the apply thread is inside the applier at emit - **only when `BatchWaits==0`**; dead in the default config | live (guard) |
| `:848-872` | `EncodeRecord`; on `kInvalidSeq` `WaitForCmdSpace(needed, 30 s)` -> `Fatal{RetirementWaitFailed,"SEG_CMD"}` | rec (the only byte backpressure) |
| `:877` `PublishAndNotify(seq)` | publish -> submittedSeq -> ring (Ring.cpp:437-462) | rec |
| `:883-884` | `ownsReplySlot` from `kMGPipeCallFlags` (14 rows) | rec |
| `:915-922` | the batch skip: `kCtxState`/`kCtxCso`/`kCtxObject` without a reply, and not `GenerateMipmap`, return without waiting | rec |
| `:924-930` | `VerbBarrier==0` negative control: nothing but reply rows waits | live (control) |
| `:932-959` | `WaitForAppliedBudget(seq)` - every `kCtxVerb` (18 rows, PipeCalls.def:200-236), every `kScreen` (12) and `kCtxQuery` (8) row, every reply row, `GenerateMipmap` | **live: R-1** |
| `:966` `DrainEventRing(m_events)` | the ONLY steady-state drain of SEG_EVENT, inside the wait ("the one instant the apply thread is known not to be inside the applier", :961-965) | live |
| `:968-998` | the reply read; `Fatal{ReplyMissing}` if the slot does not stamp seq | rec |
| `:1015-1056` `RefusePipeInputsTouchWhileApplierOwnsIt` | returns at `:1037` whenever `BatchWaits!=0`: the layer-2 gPipeInputs guard is disarmed by default | live (guard, dead) |
| `MG_Impl/Pipe/PipeFill.cpp:2962-3306` `MGPipeValidateForVerb` | client WRITES into `gPipeInputs`: `:2989` `++CurrentVerbSerial`, `:2997` `MGPipeServerClearVerbBoundary()` (withdraws the SERVER's stamp from the GL thread), `:2999-3001` `SetVerb/SetIdentity`, `:3252-3270` step 4 `CopyField` + `FilledGen[i]` stamps; `:3304` verify `EntryCompare` | live: the block is shared |
| `PipeFill.cpp:84-86` `VertexAttribDefaultsOf` | client READ of `gPipeInputs.m_currentVertexAttribute` (what the applier left) to diff `EmitVertexAttribDefaults` | live: client reads server memory |
| `PipeFill.cpp:546-556` `MGPipeNoteFrontendMutation`, `:1955+` `MGPipeLeaveVerb` | further client writes into the block | live |
| `PipeFill.cpp:815-839` -> `MG_Pipe/PipeRoute.h:270-276` -> `Client/WireTables.cpp:332-352` | `resource_subdata` carries `kReplySlot` (PipeCalls.def:188): every record `EmitAndWait`s; the Bool is **discarded** at `PipeFill.cpp:839`. This is the persistent-map push path (`PersistentMapTracker.cpp:735,:850` `PushMappedSpanBlock` -> `BufferObject.cpp:142`) - one lockstep round trip per 64 KB block, answer never read | **live, hidden** |
| `Client/EmitTables.cpp:222-234` `BeforeDrawVerb/BeforeReadOnlyVerb` | pmap push, deferred-destroy drain, GPU-write marks, before the verb record | rec |
| `EmitTables.cpp:977-1053` `EmitPresent` | `FrameSerial = 0` (:1040, "the server stamps its own"), `EmitAndWait(Present)` waits as a `kCtxVerb`; `PumpControlPlane` :1049 | live: present is a full barrier |
| `Server/PipeApplier.cpp:269-293` `OnPresent` | `table->Present()`; `m_lastPresentSerial`; **never** calls `ServerSession::ReturnPresentCredit` (ServerSession.cpp:677-682 has zero production callers; `WaitForPresentAck` Ring.cpp:495-502 is exercised only by SessionTest.cpp:522-549) | gap |
| `PipeApplier.cpp:1043-1082` `ApplyOne` | `ScopedApplierEntry` :1057, `StampVerbBoundary` :1061 -> `MG_Backend/MGPipe/PipeInputs.cpp:155-184` (writes `m_filled`, `m_currentVerb`, `m_serverStampedVerb`), `LeaveApplier` :1080 -> `:186` | rec on the server side; races the client rows above |
| `PipeInputs.cpp:191-224` `MGPipeInputUnfreshRead` | BARRIER_PULLED read in class -> `CountBarrierPull` :71-77: `rsp++`, `Fatal{UnmigratedPipeInput,...}[BARRIER-PULLED, STRICT]` only under `StrictErrors`; otherwise the read is SERVED from the client's last fill | live: the non-strict arm serves a torn value under run-ahead |
| `Transport/SessionRings.h:410-451`, `Server/ServerLoop.cpp:559-631` | `appliedSeq` +1 per record; `RetireThrough` once per drain batch (:624); the park predicate :380-383 | rec |
| `MG_State/.../BufferObject.cpp:571-651` `SyncGpuWrites` -> `Client/GpuWritePending.cpp:202-228` `AwaitBufferWriteback` | readback = reply row -> waits -> the drain at `:966` lands the writeback; `Fatal{UnimplementedWritebackWait}` if still pending | rec (stays a wait) |
| `Client/BackendObject_Remote.cpp:200,208,216,245` | the four RPC-side `DrainPublishedEvents` (surface create/resize/pbuffer, make-current) | rec |
| `ClientSession.cpp:691-786` `Stop` | `LastPublishedSeq` :739, `WaitForApplied(submitted, 5 s)` :742, `Shutdown` :755, `ServerLoop::Stop` :760, free after the join | rec |
| `MG_Pipe/PipeMutation.h:216-236`, `PipeFill.cpp:984+` | deferred destroys: an apply-thread last-owner death is queued, replayed at the next verb hook | rec (belt) |
| `Server/ServerSession.cpp:174-181,:183-254,:634-666` | SEG_EVENT producers: lossless, `Fatal{EventRingOverflow}` on a full 256 KiB ring (SessionRings.h:96) - "a full ring under lockstep is a burst no workload has" | live: premise dies with the lockstep |
| `MG_Impl/Pipe/SlotAllocator.cpp:22-38` | `MGPipeRefuseAllocatorFromApplyThread` exempts the two scopes at `:28-29` | live (the exemption) |
| `ConfigLoader.cpp:377` | `PipeVerify -> BatchWaits = 0`: the verify build forces the per-record barrier (confirmed) | rec |
Sizes for §3(2): DrawVbo = 8 B header (Ring.h:164) + 56 (`MGPDrawInfo`, MGPipeTypes.h:1441) + 12 per range (:1448) -> 80 B padded; Present 16 B; `resource_subdata` 72 B + its SEG_STAGE run.
## 2. What the transport already carries for run-ahead
- Five watermarks on `RingControl` (Ring.h:138-139 `completedFrameSerial`, `presentAckSerial`); `SessionProducer::WaitForPresentAck` (Ring.cpp:495-502, bounded, `ShutDown` result), `SessionConsumer::ReturnPresentCredit` (Ring.cpp:607-613, advances AND rings), `ServerSession::ReturnPresentCredit` (:677-682), `MGPPresent::FrameSerial` (client sends 0), `m_lastPresentSerial` on the sink.
- The class/flag tables at the emit site (`MGPipeCallClassFor`, `MGPipeCallFlagsFor`, `ClientSession.cpp:884,:917`) - the wait rule is already table-driven for one class.
- `SEG_CMD` backpressure (`WaitForCmdSpace`), `SEG_STAGE` retirement on `retiredSeq` (Wire/PipeWireCodec.cpp:826-842, 5 s bell wait; `ReclaimStagedBytes` :1126-1163 is already written for "pipelining deeper than fully drained", :1138-1144).
- `EventRingConsumer::RingIsFull()` / `Drained()` clear the `eventRingFull` latch (Transport/EventRing.h:185-212); the producer latches it at `:135-143`; ARCHITECTURE.md §11.7 (`:419`) already specifies the two-sided protocol (server stops applying at a record boundary, rings; client drains, clears, rings).
- The server stamp's constexpr answerable mask (PipeInputs.cpp:104-141) and the strict detector (`:61-67`); `MGPipeServerClearVerbBoundary`; `ScopedApplierEntry` (ClientSession.h:155-160); `InBarrierWait`/`ApplyThreadIsInsideApplier` (:1010-1013).
- `CapsMirror::HasCap` (Client/CapsMirror.h:71-72) over `MGPCaps::CallMask`; bits 0..9 used (MGPipeTypes.h:108-135); the server sets the mask by backend type (ARCHITECTURE §17.2).
- `IpcTable` (Config.h:465-518) + `InitIpc` (ConfigLoader.cpp:356-403); `PRESENT_CREDIT` is already in the planned inventory (Config.h:462, ARCHITECTURE.md:469 "default 1", :619).
- Deferred-destroy queue, `object_death` (kCtxObject - already batched), `applier_reset` (kScreen - waits), `DrainPublishedEvents`, the wait ledger (`Waits()/Parks()`).
## 3. The gap - knobs, fields, records, rules that do not exist
1. **`IpcTable::RunAhead`** = `MOBILEGL_IPC_RUN_AHEAD` (default 1; forced 0 by `PipeVerify` beside `:377`; meaningless when `VerbBarrier==0`). **`IpcTable::PresentCredit`** = `MOBILEGL_IPC_PRESENT_CREDIT` (default **1**, range 1..8 - see §4(2)).
2. **`kCapRunAheadApply = 1ull << 10`** in `MGPCapBit`; set in the DirectGLES arm of the server's `CallMask`, never in the Magma arm. Client predicate `ClientSession::RunAheadArmed()` = `m_barrierArmed && Ipc.RunAhead && Caps().HasCap(kCapRunAheadApply)`, latched at the first CapsSnapshot adoption after `Start` (a later snapshot may only turn it OFF - a caps re-run cannot make a lockstep server run-ahead mid-frame).
3. **A wait class per op**, generated beside `kMGPipeCallFlags` (`MGPipeWaitClassFor(op)`): `kWaitReply` (every `kReplySlot` row), `kWaitApplied` (`ApplierReset`, `FenceWaitServer`? - no: GPU-side, fire-and-forget; only `ApplierReset` and `GenerateMipmap` until S2 retires the `GetActiveTextureUnit` read at DirectGLES `GenerateMipmap`, ClientSession.cpp:900-914), `kWaitPresent` (`Present`), `kWaitNone` (everything else, including `ResourceDestroy`, `UnmapPersistent`, `FenceCreate/Destroy`, the five non-reply query rows, all `kCtxVerb` rows but `ReadPixels`/`Present`). Row-level override, not a per-op runtime guess.
4. **`resource_subdata` without a reply for the buffer half.** The row keeps `kReplySlot` on the wire (the server posts; an unread slot is harmless: the client reads a slot only after `appliedSeq >= seq`, and a slot is rewritten only by a record 8 seqs later, ReplySlot.h:16,:105); `Wire_ResourceSubData` (WireTables.cpp:332-352) takes a `wantReply` from the route: `MGPipeRouteResourceSubData` passes `nullptr` for buffers (the Bool at PipeFill.cpp:839 is discarded today) and the texture drain's D-D5 acceptance is a ruling for S2/S8 (see §7). Without this the pmap push stays lockstep.
5. **`MGPPresent::FrameSerial` minted by the client** (`++m_presentsSent`, 1-based); `OnPresent` calls `ServerSession::Active()->ReturnPresentCredit(present.FrameSerial)` after `table->Present()` returns (m-1's 1:1 holds: one credit per swap). Client: `WaitForPresentAck(m_presentsSent - PresentCredit)` BEFORE encoding present N when `m_presentsSent - PresentCredit >= 1`.
6. **`gPipeInputs` client-side skip.** In `MGPipeValidateForVerb`, when `RunAheadArmed()`: skip `:2989`, `:2997`, `:2999-3001`, the whole of step 4 (`:3252-3270`), and `MGPipeLeaveVerb`'s writes; `MGPipeNoteFrontendMutation` returns at `:554`; step 2 (tracker walk) and step 3 (emitters) run unchanged. `VertexAttribDefaultsOf` (:84) must diff against the emitter's own last header (`g_attribDefaultLastHeader`, :2949) - a client mirror, never the block. **One-time release of the four O-class SharedPtr rows** (`PipeInputs.h:801-809` `m_boundVertexArray`, the three programs) at the switch, on the GL thread, or the last lockstep fill pins those objects for the process life and the apply thread can become their last owner. `SetIdentity` (:3001) moves to the server (`ApplyOne` sets it once from its own backend).
7. **Server stamp under run-ahead.** `MGPipeServerStampVerbBoundary(verb, runAhead)`; `MGPipeInputUnfreshRead` under `runAhead` takes `StrictBarrierPullFatal` **regardless of `StrictErrors`** (the value in the row is torn or stale by construction, P5e-3); `CountBarrierPull` stays for lockstep. The sticky forwards (`MGPipeStickyForwardPull` :251-257) are the same: Fatal under a run-ahead verb.
8. **Event-ring flow control** (ARCHITECTURE §11.7 made real): server `Reserve==nullptr` -> latch `eventRingFull` (exists), publish, `NotifyClient`, then park in `ServerLoop::ApplyThreadMain`'s loop with `eventRingFull==0` added to `ready` (:380-383) - NOT `Fatal{EventRingOverflow}` when `RunAheadArmed`; client: every `Park` exit in `ClientSession` (applied, present-ack, cmd-space, and the stage bell at PipeWireCodec.cpp:836, which is the same `SelfDoorbell`) re-checks `m_events.RingIsFull()` and drains before re-parking. Deadlock argument: the server blocks only on the event ring; the client blocks only on watermarks the server advances; the client's every wait drains; so at most one side is parked at any instant.
9. **Deferred destroy under run-ahead**: keep the queue; `MGPipeDeferDestroyAndFreeIfOnApplyThread` under `RunAheadArmed` logs `MGLOG_E_ONCE` with the kind (after S1-S7 the server holds no frontend SharedPtr; an enqueue is a finding) - Fatal in strict.
10. **`RefusePipeInputsTouchWhileApplierOwnsIt`** is re-derived: under run-ahead the rule is "the GL thread never touches the block", so the guard becomes unconditional on the surfaces named in (6) when `RunAheadArmed()` (the `BatchWaits` early return at `:1037` goes; the check no longer consults `ApplyThreadIsInsideApplier`). `Fatal{BarrierViolation}` (:840) stays for `BatchWaits==0 && !RunAhead`.
## 4. The retirement design
**(1) The wait rule.** `EmitAndWaitTails` after `PublishAndNotify`:
```
if (ownsReplySlot) -> WaitForAppliedBudget + DrainEventRing + ReadReply (unchanged)
else if (RunAheadArmed()) switch (MGPipeWaitClassFor(op)) {
kWaitNone: return seq;
kWaitApplied: WaitForAppliedBudget + DrainEventRing; return seq; // applier_reset, generate_mipmap (until S2)
kWaitPresent: (credit wait happens in EmitPresent BEFORE encode; here: return seq) }
else -> today's :915-959 shape, byte for byte
```
`VerbBarrier==0` keeps its meaning (nothing but reply rows waits) and stays the R-1 negative control for the lockstep arm; under run-ahead its expected red is `applier_reset` ordering (`Fatal{ProtocolCorruption,"ApplierReset.ContextSerial"}` cannot fire - the ring orders it - so the control has to be re-pointed at the strict detector: §6). Magma: `kCapRunAheadApply` absent -> `RunAheadArmed()==false` -> today's path; the negative control for the switch is `MOBILEGL_IPC_RUN_AHEAD=1` on Magma, which must log "run-ahead requested, server does not publish kCapRunAheadApply" and run lockstep.
**(2) Present pacing.** `EmitPresent`: `BeforeReadOnlyVerb(); if (m_presentsSent >= credit) WaitForPresentAck(m_presentsSent + 1 - credit, 30 s) [ShutDown -> return; TimedOut -> Fatal{PresentCreditTimeout}]; DrainEventRing; encode/publish; PumpControlPlane`. With credit 1 the client publishes frame N+1's records while the server applies and swaps frame N and blocks at present N+1 until swap N returned - one full frame of overlap, the shape ARCHITECTURE §14 defaults to; credit 2 adds one frame of end-to-end latency for no CPU gain on a CPU-bound client. **Default 1.** Ring budget at ~850 draws + ~0.36 MB pmap/frame: SEG_CMD in flight per frame is ~68 KB of draws + the set/CSO records (bounded by the same frame) - well under 1 MiB, so 8 MiB holds 8+ frames; SEG_STAGE at 0.36 MB/frame + subdata/texture blobs holds 32 MiB / ~1 MB = ~30 frames; SEG_REPLY unchanged (reply rows still one deep). Backpressure is therefore the present credit, never the bytes; `WaitForCmdSpace`/the stage bell are the safety net and both must drain events on exit (§3(8)).
**(3) Drains and forced waits.** Drain points: every wait exit (reply rows, present credit, cmd-space, stage), the four RPC sites, and `Stop`. Client operations that force a wait: `MapBuffer(READ)` / `GetBufferSubData` / `CopyBufferSubData` source -> `SyncGpuWrites` (reply row, unchanged); `glFinish` -> emits `Flush`? No: `Flush`/`Finish` stay no-ops on the wire (ARCHITECTURE:415); `glFinish` under run-ahead = `WaitForApplied(LastPublishedSeq)` + drain, spelled in the client `Finish` slot (new, one line); `glClientWaitSync` / `GetSyncStatus` / `QueryAvailable` / `QueryResult` are reply rows (wait); `glGetError`: **the relaxation** - server-side GL errors (`kEventGlError`, DirectGLES.cpp:8804 -> ServerSession.cpp:634-666) are observed at the next drain, so `glGetError` reports a run-ahead verb's error at or after the next wait point rather than at the call after it; CONTRACT-P5C §4.2 already assigns the ordering to P9; P5e names the window as "<= one present credit". `eglMakeCurrent` / surface RPCs: `RunOnApplyThread` already serialises with the ring? NO - the mailbox is pumped between `DrainRing` batches (ServerLoop.cpp:385-390), so a make-current can land between two run-ahead records. Rule: the client waits `WaitForApplied(LastPublishedSeq)` before every `Server*` EGL forwarder (BackendObject_Remote.cpp) - the "control records that need it" of P5e-2; `applier_reset` is the same, it is a `kWaitApplied` row.
**(4) `gPipeInputs` ownership.** Under a live wire and `RunAheadArmed()` the block is server-role memory: the client executes steps 2 and 3 of `MGPipeValidateForVerb` and nothing else; the server stamps at `ApplyOne` (unchanged), `SetIdentity` once per session; the detector is (§3(7)); the verify build forces `RunAhead=0` (comparator needs a client-filled block, PipeFill.cpp:3304). The residual-value trip wire (`:3284-3294`) still emits - it compares on the SERVER against the applier-derived mirror, which is the honest half. The `MGP_FILL` call sites (GL_Drawing.cpp:540-640) do not change.
**(5) Object lifetime.** ABA: the client frees a slot right after emitting the destroy (PipeFill.cpp:979-980) and the next `Acquire` bumps Gen; every record naming `{slot,gen}` precedes `destroy{slot,gen}` on the ring and every record naming `{slot,gen+1}` follows `create{slot,gen+1}`; the in-order ring plus a `Gen` compare in every server twin table (S7's rule) closes it - no drain wait is needed at delete. `object_death` is already batched; `resource_destroy` becomes `kWaitNone`. The published latch is keyed `{kind,slot,gen}` (PipeMutation.h:294-310). Persistent maps: `Forget()` (PersistentMapTracker.cpp:599-606) drops tracker state only; the pushed bytes were COPIED into SEG_STAGE at `StageOptional` (WireTables.cpp:342) and reclaim waits on `retiredSeq`, so a map released while blocks are in flight is safe; a `glBufferData` respecify after in-flight subdata is ordered by the ring. Client memos holding frontend->handle maps (`MGPipeResourceTracker::Find`, `g_fenceProxies`, the emitters' latches) are GL-thread-only and untouched. Fence: `EmitDeleteSync` frees the Fence slot after emitting (EmitTables.cpp:1507-1519), same ABA answer.
**(6) The two scopes.** After S7 the guard at SlotAllocator.cpp:22-38 loses `:28-29`: `MGPipeSlots()` from the apply thread is unconditionally `Fatal{RoleViolation,"MGPipeSlots"}`. The 36 sites (DirectGLES.cpp 20, Managers.cpp 15, ResourceTracker.h:723) by owner: draw-path (S1 1609, S3 2588/3303/4585, S4 4236/4329/5328/5689, S2 1734/4926/4993, S5 Managers.cpp:2938 `HandleOfBuffer`) - retired by their designs. **Not covered by S1-S5, and each is either a run-ahead op or a waited op:** `DrawArrays`/`MultiDrawArrays` client-side attribute arms (6729/6775, run-ahead ops - refused under split already? S1 confirms; if refused the scope is dead code); `GetBackendProgramId` (6166) and `ShaderStorageBlockBinding` (10029) - `set_storage_block_binding` is a run-ahead `kCtxVerb` that resolves the program via `GetProgramObject(GlName)` (PipeApplier.cpp:805-808): must resolve through `MGPStorageBlockBinding::ShaderCso` (carried, unread) -> S4's twin; `ScopedCompleteFramebufferBinding` (8837, texture `Find` inside blit/readback helpers) and the mipmap generators (9161/9216 `HandleOf(texture)`) - `GenerateMipmap` and `CopyFramebufferToTexture` are run-ahead ops whose handle already rides the record (`VerbMipRes`, `VerbCopyTexDst`); the probe must go by-handle; `SyncRenderbufferObjectToBackend` (9695) - renderbuffers have a create/respecify handle (P4a) -> `FindByHandle`; `GetTexImage` (11884) - a reply row (`get_texture_image`), waited, but still an apply-thread probe: by-handle via the record's `Res`; Managers.cpp 256 (`OnFrontendStateObjectDestroyed`, the mailbox arm - dies with the mailbox), 4544/5882/7087/8481/8717/9256/9310/9709/10382/10403/12949/13273/13570 (sampler view, texture id, texture view, FBO `InvalidateSyncedState`, draw-buffers, shader compile buffer-texture, sampler ctor, renderbuffer bind) - S7's re-key list; `ResourceTracker.h:723` (`MGPipeAnnounceBufferGpuWritten`, Magma) - lockstep only, stays behind `!RunAheadArmed`. Readback paths (`resource_readback`, `read_pixels`, `get_texture_image`) are WAITED ops but the wait no longer makes a frontend read legal (the client runs on after publishing everything before it): they resolve by handle too.
**(7) Measurement plan (Redmi, MC 26.3 VD12).** Arms: monolith; inproc lockstep (`MOBILEGL_IPC_RUN_AHEAD=0`); inproc run-ahead credit 1; credit 2. Per arm: 30 s in-world window, fps, per-thread CPU ms/frame (client GL thread, `mgl-srv-apply`), `cliwait/clipark` and `srv/srvpark` from the wire ledger and the stats line (ClientSession.cpp:1161-1189; ServerLoop.cpp:410-414), `simpleperf` on both threads (target: `WaitForApplied` self on the client falls from 27.9% toward the present-credit wait only; `DrainRing` real work unchanged), `rsp` = 0 in every arm's summary, SSIM of the four A/B traces against monolith. The bench scripts named in the brief (`~/w7/notes/tools/p5d_bench_{fcl4,all4}.sh`) are not on this machine; the report's §"数字" method (P5D-INPROC-PERFORMANCE.md:58-71) is the template.
## 5. Monolith / G1
Every change above is `#if MOBILEGL_BUILD_DISAGGREGATED` and behind `RunAheadArmed()`, which is false with no session: `Transport=monolith` never constructs a `ClientSession`, so the emit tables are the monolith adapters and none of `EmitAndWaitTails`, `EmitPresent`, the stamp or the guards runs; `MGPipeValidateForVerb`'s skip is a runtime predicate inside the existing `MOBILEGL_BUILD_DISAGGREGATED` block (:2964-2972 shape) - the pull build's bytes do not move. The one server-side file with a monolith arm, `PipeInputs.cpp`, is compiled only under `MOBILEGL_PIPE_PUSH` and its new `runAhead` argument defaults false. `PipeCalls.def` does not change (no new opcode: the wait class is a generated table, the caps bit is a value).
## 6. Red-once
1. Revert the present-credit wait (encode present without `WaitForPresentAck`) -> `SessionTest` twin of `:522-549` in `RemoteClientTest`: a client with credit 1 and a stalled apply thread must park; without the wait the third present publishes and the case reads `Waits()` unchanged -> red by count.
2. Revert §3(6) (leave one `CopyField` in the run-ahead fill) -> the layer-2 guard fires `Fatal{RoleViolation,"gPipeInputs"}` by name (`RemoteGuards.ClientPipeInputsFillWhileTheApplierOwnsItIsFatalByName`'s shape, RemoteClientTest.cpp:1989 with `RunAhead=1`).
3. Revert §3(7) (count instead of Fatal under a run-ahead verb) -> the `integration-split-strict` lane (test.yml:1140-1206) turns from expected-red into the hard green gate: rewrite its step so GREEN is required for the migrated verbs and any `[BARRIER-PULLED, ...]` marker is red; a revert of any S1-S5 handle arm then goes red HERE, not in a picture.
4. Revert §3(8) (keep `Fatal{EventRingOverflow}`) -> a unit case that publishes 300 KiB of `kEventGpuWritten` behind an unwaited run-ahead sequence aborts by name; with flow control it completes.
5. Revert §3(2) (publish the caps bit for Magma) -> `MagmaPipeIdentityTest`/`ServerLoopTest`: a Magma-typed server's `CallMask` must not carry bit 10.
6. `MOBILEGL_IPC_RUN_AHEAD=0` is the A/B control and must render identically (SSIM); `MOBILEGL_IPC_VERB_BARRIER=0` stays the lockstep arm's negative control.
## 7. Risks and unsettled
- **`resource_subdata`'s texture-half acceptance (D-D5)**: `DrainTextureSubData` clears a level's dirty flag on an accepted reply. Making the row `kWaitNone` requires "accept by construction / decline is Fatal" on the server, or the pmap push keeps a wait per block. Needs S2 + integrator ruling; without it run-ahead only covers draws and the pmap path stays ~6 round trips/frame.
- **`GenerateMipmap`** waits until S2 retires the `GetActiveTextureUnit` read; **`set_storage_block_binding`** and **`resource_copy_region`** (`GetTextureObject(GlName)` sticky, PipeApplier.cpp:742-743) are `kCtxVerb` rows that still read client state - either wait (`kWaitApplied`) or S2/S4 migrate them; list for S8.
- **GPU-write marks are client-side and conservative** (GpuWritePending.cpp:141-147); `OnGpuWritten` events from the server (`MarkBufferGpuWritten`) now arrive late - the client's own marks already cover the draw-time set, so late narrowing is harmless; confirm no server-only producer exists for XFB (`EndStreamOutput` is client-marked, :164-167).
- **EGL RPC vs run-ahead** (§4(3)): the wait-before-forwarder rule adds one full drain per make-current/surface event; acceptable (startup-time), but `ServerSwapEGLBuffers` is NOT on that list (present is the swap).
- **Event-ring capacity** (256 KiB): a writeback slice is a quarter ring (GpuWritePending.cpp:187-200) and the readback row waits, so flow control is the only new requirement; consider `MOBILEGL_IPC_EVENT_KB` if the per-draw `kEventGpuWritten` traffic under SSBO workloads (Iris) trips the full latch every frame.
- **The `VerbBarrier==0` control's meaning** after P5e; and whether `Flush` (kCtxVerb, emitted?) should become the doorbell point of ARCHITECTURE §11.6 - not settled here.
- **Thread placement** (P5D report §"什么没完成" 2) is out of scope by the user's instruction; the measurement plan reports per-thread ms/frame so the comparison survives it.