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

25 KiB

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 EmitAndWaits; 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.