- ReadFenceReply (EmitTables.cpp) and ReadQueryReply (QueryEmit.inc) tested `status != kStatusOk`,
which is two values in one branch. ReplySink's own header says "DECLINED IS A REAL ANSWER, not
a failure" (PipeWireCodec.h:457-460), and the escape rows are held to the other half of the same
rule - "ERROR IS NOT A DECLINE ... an escape may not fold 2 into `false`" (WireTables.cpp:446-450).
- BOTH VALUES OF 1 ARE REACHABLE WITHOUT A BYTE OF CORRUPTION: the server declines these five rows
by design whenever it cannot answer them (PipeWireCodec.cpp:1820-1830 for the two fence rows,
:1841-1861 for the three query rows - a zero-length answer to a kWaitReply row, R-5), and the
client produces the same status itself once the session stops carrying records
(ClientSession.cpp:1837 the device-lost latch, :2025 a doorbell that died during teardown, where
the comment says outright that "the verb did not happen" is reported as DECLINED and not ERROR).
- WHAT THE FOLD COST: after a clean device loss Minecraft 26.2's next glClientWaitSync - the
chunk-upload fences - died of Fatal{ProtocolCorruption, "Fence.reply"} (rc=134) instead of taking
the no-op the design promises ("GL calls become no-ops", design/08-runtime-and-platform.md:33).
The query rows had the same shape waiting for them.
- The no-op is the one this frontend already gives for a sync it cannot wait on: GL_Sync.cpp:103-106,
PipeApplier.cpp:175-177 and DirectGLES.cpp:16489 all answer GL_ALREADY_SIGNALED. A query row
answers its own "no answer yet" value - availability false, Produced 0 (which the caller already
returns as false with the out-parameter untouched), timestamp 0.
- ERROR and a short OK keep the named Fatal they always had: a transport fault is not the server
saying no, and an OK answer that arrived empty is a reply nobody can read. ReadPixels keeps its
deliberate Fatal{ReadbackDeclined}: unlike these rows a blocking readback cannot succeed short of
complete pixels, and that ruling is untouched.
- PipeFill.cpp's UncarriedInitialBytes self-check now spares a session that has latched device-lost.
The resource_subdata it counts is DECLINED by design there, so "it emitted nothing" is the latch
working rather than bytes this client forgot to carry - and there is no server left for them to
exist on. P12's exit gate (a) is that killing the server gives a CLEAN device-lost latch, not a
corpse at the next glBufferData that carries data.
- Four cases in RemoteClientControls.inc drive the production consumers over a live session whose
peer declines them. Red-once run for all three Fatals (Fence.reply, Query.reply,
UncarriedInitialBytes), and ErrorFenceReplyStillRefusesByName is the control that keeps the
tolerance from becoming blanket. RemoteClientTest 166/166; the host unit lane 2610/2610.
- Split only: both consumer files compile under MOBILEGL_BUILD_DISAGGREGATED and the PipeFill hunk
sits inside its `#if`, so a pull build preprocesses to exactly what it had (G1).
Step two of CONTRACT-P6 5.2. The ~90 scattered `MGLOG_F("...Fatal{...}"); std::abort();`
pairs under MG_Remote/ become one call each to SessionFail(MGFatalFamily, fmt, ...),
which logs, echoes to stderr, counts, and aborts in one place. a6's census
found no single chokepoint existed; now there is one, and step three hangs the
SessionFault-to-peer publish off it.
THE MESSAGE STRING IS PASSED VERBATIM. The contract requires the log lines, the
CI greps and every red-once byte-identical, so SessionFail does not compose the
line from the family - it takes the SAME string the site handed MGLOG_F and
forwards it unchanged. The family enum rides alongside, redundantly, so the
funnel has the coarse wire FatalCode without parsing the string. The census now
checks that redundancy the way it checks WireLogFatal: every SessionFail call's
string must carry a Fatal{ word, so the enum and the string cannot silently
disagree.
The conversion was mechanical - 89 sites by a transform that injects the family
as MGLOG_F's new first argument and drops the trailing abort - with three sites
fixed by hand that the transform would have broken: a stub macro, and two
if/else-then-shared-abort blocks (PersistentMapTracker's adopt-tier and
SurfaceOpCodec's three surface-error branches) where a naive rewrite stole the
abort from the earlier branches and would have let them fall through. The
death tests are the safety net for byte-identity, and every one stayed green.
Four std::abort() remain under MG_Remote/, all inside the two funnels
(WireLog.cpp, FatalFunnel.cpp); the census exempts both files because their
callers carry the family word, and its baseline drops from 93 aborts to 4.
SessionFaultCount() is the one telemetry point S8 will assert zero over a good
run.
G1 re-measured: 0 symbol diff, .text unchanged at 0xa52203 (FatalFunnel and
FatalFamily live under MG_Remote/, compiled only in the disaggregated build).
Unit 2358 (one pre-existing environmental failure), integration-split 186/186,
integration-spawn 102/102, retrace 6/6, census 0 unmarked.
R-10's "P5 does not chunk (R-10)" wording predates the stage chunk budget: buffer
content ranges are cut at MGPipeStageChunkBytes() (1e7c372e) and a texture level
at whole-width slabs (9469d48e), so the claim is false for the two rows that can
outgrow SEG_STAGE - and these messages are what a bring-up reads.
- PipeWireEncoder::StageAllocate's two Fatal{RingOverrun, "SEG_STAGE"} sites now
say what reaching them means: a blob is staged whole, a row that cuts its
content at the stage chunk budget never arrives, so this is a record type with
no cut or a piece still larger than the arena.
- EncodeRecord's MaxRecordBytes arm says the bound is about the record's own
bytes - the stage chunk budget cuts blobs, not a header, payload or inline
tail - and keeps the integrator decision it names.
- Comments in PipeWireCodec.h, EmitTables.cpp's maxrecop line, Config.h's
STAGE_MB knob, PipeStats.h's MaxRecordBytes gauge, SplitRuntimePeek.h and
WireLedgerChecks.h said P5 does no chunking; they now say what is cut and what
is not.
- PipeWireCodecTest.ARecordLargerThanHalfTheRingIsFatalRingOverrun asserts the
new diagnostic text instead of "does not chunk (R-10)".
- ReadDrawBindings answers ClientVertexArrays in the SAME single read of the bindings the
rest of the draw plan is made from: an enabled attribute with no buffer behind it, which is
exactly what EmitVertexBuffers publishes as Res == kMGPipeNullHandle.
- PlanDrawInfo sets kDrawClientArrays for every draw shape, not only the array ones - a
glDrawElements can fetch its VERTICES from client memory too - because MGPipeBarriered is
computed from the record on both sides and a null Res inside the window is also what a
disabled attribute publishes.
- EmitDrawRecord and the reduced DrawArrays path refuse such a draw under run-ahead with
Fatal{UnmigratedVerb, "DrawArrays+CLIENT_ARRAYS"} on the GL thread, before the hooks
(ID-82 / ruling 2). LOCKSTEP IS UNCHANGED: the client is parked in WaitForApplied for that
record, so the server's upload reads memory that is not moving (ruling 4). Staging the
bytes as a per-attribute {BindingIndex, MGHostSpan} tail is P8's.
- The armed predicate is spelled here from the three inputs MGPipeTypes.h names for
RunAheadArmed() rather than called, because ClientSession::RunAheadArmed is package ra's;
it is inert until the integration commit publishes the caps bit, and ra collapses this
body into that latch.
- CONTRACT-P5E §2.3. EmitAndWaitTails now switches on the generated WaitClass column
when RunAheadArmed(): kWaitNone and kWaitPresent return at the publish, kWaitApplied
waits and then releases the fill pins, and a reply row is unchanged. It REPLACES the
BatchWaits skip rather than layering on it - the batch rule reads the call CLASS and
this one reads what an apply READS, and the server's MGPipeBarriered reads the second,
so two rules layered is two halves of the wire disagreeing about whether the client is
parked.
- §1: RunAheadArmed() = barrierArmed && Ipc.RunAhead && Caps().HasCap(kCapRunAheadApply),
latched at the first caps adoption after Start and demotable but never promotable - a
record already published without a wait cannot be un-published. A knob set against a
server that does not publish the bit logs once and runs lockstep.
- §2.4: MGPPresent::FrameSerial is minted client-side, 1-based, by AcquirePresentCredit,
which pays the credit BEFORE the encode (a client that encoded first would hold a
SEG_CMD reservation across the park). ServerVerbSink::OnPresent returns one credit per
swap through ServerSession::ReturnPresentCredit, which has had no production caller
since v1 wrote it; serial 0 on a run-ahead server is Fatal{ProtocolCorruption,
"Present.FrameSerial"}. The stats line and the wire ledger gain credit-waits.
- §2.5, the drains and the forced waits: every wait exit drains SEG_EVENT (the applied
wait, the present-ack wait, the cmd-space wait and - through a hook the session lends
the encoder - the stage bell); glFinish becomes WaitForApplyToCatchUp, since "the
commands issued so far have completed" is a promise a queue nobody waits on can break;
every Server* EGL forwarder waits first, because the control mailbox is pumped BETWEEN
drain batches and a make-current landing between two run-ahead records is not a wrong
pixel but a wrong everything. ServerSwapEGLBuffers is not on that list: present is the
swap.
- §2.5 / ruling 15 (ID-93): resource_subdata's BUFFER half stops waiting. The row keeps
kReplySlot, the server still posts, and the slot is never read - which is what makes
the 64 KB persistent-map push fire-and-forget instead of a hidden round trip per block.
MGPipeSubDataWantsItsReply is the one predicate, read by the route and by the emit
table, so the two cannot disagree about whether a wait is owed. The texture half keeps
its reply: DrainTextureSubData clears a level's dirty flag on the accepted answer.
- §3: gPipeInputs becomes server-role memory for unbarriered records. The tracker walk
and the emitters still run - they produce records - but the serial bump, the verb
stamp, the server-stamp withdrawal and the whole of step 4 do not, and
MGPipeNoteFrontendMutation / MGPipeLeaveVerb return instead of writing. `fillOwed` is
a separate name from `barriered` on purpose: the red-once is that one line.
- §3.3: MGPipeInputUnfreshRead's counter becomes an UNCONDITIONAL abort under an
unbarriered record, knob or no knob, because the value is torn or stale by
construction and there is nothing for a counter to count. The marker gains its reason
so the lane's allowlist can tell the two apart.
- §3.5: RefusePipeInputsTouchWhileApplierOwnsIt is re-derived - under run-ahead the rule
is "not at all unless you are the barriered fill", and the BatchWaits early return
goes with the argument that justified it.
- §2.3's ReleaseFillPins drops the four O-class SharedPtr rows once a barriered apply
returns: under lockstep the next fill overwrote them within a verb, under run-ahead a
pinned VAO can make the apply thread its last owner a frame later.
- §2.7 / ruling 13 (ID-91): the deferred-destroy queue stays live, and an enqueue from
an UNBARRIERED apply is now a named finding - loud once, Fatal under strict.
- NAMED DEVIATION from §3.1: the identity/liveness pair is still written on every verb.
It is not a field of the residual model - no ownership row owns it, no record carries
it, no server stamp can answer it - it is "does this process still have a GL context",
which only this thread knows and every null-context guard reads.
- Inert until the integration commit: RunAheadArmed() is false while
kMGPipeP5eRunAheadReady is, so every branch above answers as it did before.
- The round-2 report guessed the apply thread's ~48% mutex time was the backend
pendingMutex; the symbolized call graph at 56a77348 says it is the idle poll in
ServerLoop::ApplyThreadMain taking m_controlMutex twice per iteration
(ControlIsPending in the ready lambda, PumpControlRequest), with DrainRing at
29% of the thread. Every package below is cut from that graph, not from self%.
- T (transport): the control mailbox gets an atomic shadow bit m_controlPosted so
the idle poll takes no lock (handshake, C2 critical section and the
NOT_INITIALIZED answer unchanged); Doorbell::Wait spins on a once-calibrated
iteration budget and reads no clock in steady state (the park phase alone
computes deadlines); client/server wait and park counts ride the stats line
as wait[srv= srvpark= cli= clipark=] and the wire ledger as cliwait=/clipark=.
- D (role guards): OnApplyThread() is inline - one relaxed atomic key against the
caller's thread pointer (aarch64 __builtin_thread_pointer, else
std::this_thread::get_id) - and m_applyThreadId, the function-static guard and
the pthread_equal call are gone; the three scope depth counters stop being
thread_local (their only reader is the apply-thread guard, so they count only
when OnApplyThread() is true; a GL-thread scope no longer exempts the apply
thread, pinned by a new case); the guard chains are ordered cheapest-first.
- B (persistent maps): the shadow allocator rounds the SIZE to a page multiple, so
TrackWriteMap aligns OUTWARD when the shadow is page-granular - a sub-page map
is one protected page, edges and their per-draw XXH3 disappear; the inward arm
stays as the fallback. Review found a hole the edge hashes had hidden since
round 1: a map that faulted but was not consumed by the walk that advanced the
epoch was never pushed. The walk now drains every marked member before the
binding walk, registration bumps the epoch so a fresh map ships once on the
draw path, and a non-4 KB kernel page refuses the mprotect arm by name.
- C (client per verb): an image-unit high-water mark (push build only, fed by
glBindImageTexture) replaces the 192-unit sweep per draw; the residual fill's
7-term supplied conjunction is a memo keyed on its four inputs; the server's
63-field stamp per verb is a constexpr per-class mask table.
- generate_mipmap no longer skips its wait under MOBILEGL_IPC_BATCH_WAITS: its sink
is the backend GenerateMipmap, which reads MGB_CTX->GetActiveTextureUnit(), and
a batched publish let the next validate move the serial under it -
Fatal{UnmigratedPipeInput, "GetActiveTextureUnit@ReadPixels"} on the gate tree.
The rule is written at the site: a record may skip its wait only if its apply
reads nothing the residual fill writes. The texture-death scenario gets the
framebuffer case's fence before it reads the object_death tally.
- Redmi 2f7cbe2e, FCL + Minecraft 26.3-rc-3, VD12, CPU pinned: inproc 64.3 fps
(client 15.0 ms/frame, apply 12.4) -> 103-114 fps (client 8.7-9.2 ms/frame on a
mid core, apply 7.0-7.4); monolith 115 (120 Hz vsync-capped runs) / 206 (one
uncapped run, 5.0 ms/frame on the big core). Gates: unit 2203/2203,
integration-split 111/111 (the two formerly flaky cases 6/6 on repeat),
generators, include closure, dirty surface and doc citations green.
VD12 Minecraft on the Redmi (FCL, DirectGLES): the split's in-world frame rate
13 -> 60-66 fps against monolith's 110-114, and persistent-map wire bytes
25 MB/frame -> ~0.23 MB/frame. The residual split-specific costs are named at
the end, not hidden: the R-1 serialization (WaitForApplied ~12%, P11's
versioned-inputs design) and a split-only mutex complex still under
investigation.
Persistent-map push, three arms picked per buffer at map time:
- mprotect dirty tracker: the mapped range's page-aligned interior is
protected PROT_READ; the app's next write faults, the SIGSEGV handler marks
the page in the slot's bitmap and unprotects, and the push ships one 64 KB
block per block containing a faulted page. Ownership is three tests -
SEGV_ACCERR only (a userspace raise and an unmapped page chain verbatim),
inside a live tracked range, and not a refault on a still-set bit (a write
we unprotected succeeds, so that is a non-write access and it crashes
honestly). The alignment is INWARD: an outward-aligned edge page holds
foreign bytes, and a signal-blocking worker thread faulting on one is a
process kill - that was the ctest-only SmallRing segfault. Edge spans
(at most two per map) are hashed per push and ship their containing block
only on change (unconditional edges measured 112 MB/frame). Re-arm is one
mprotect per contiguous run. The first push ships everything once.
- hash-suppress arm (MOBILEGL_IPC_PERSISTENT_HASH_SUPPRESS, default 1):
per-block XXH3 against the last push's hashes; untracked buffers only.
- PushDrawConsumers: only the buffers the upcoming draw/dispatch can read
(VAO attribs/element, indexed binding points, indirect/parameter slots,
TexBuffer backing - the last also retires the per-frame-cadence cloud
twitch), plus the fault-epoch skip: an unmoved epoch proves no interior
byte changed, tracked edges are hashed in place, and any hash-arm member
vetoes. The membership map caches the tracker slot pointer (a 64-slot
rescan per buffer per draw was measured and removed).
MOBILEGL_IPC_BATCH_WAITS (default 1; MOBILEGL_PIPE_VERIFY forces 0): a
value-class record (kCtxState/kCtxCso/kCtxObject with no reply slot) is
published without waiting for its own apply; the barrier is taken at the next
pull-reading verb, the only place BARRIER-PULLED fields are read, so the R-1
fence posts are untouched and only the round trips between them change. Two
consequences are pinned rather than argued: kCtxObject verbs the tests read
synchronously get explicit fences (GenerateMipmapFieldsCross,
FramebufferDeathCrosses), and a frontend object whose last SharedPtr drops on
the apply thread - impossible before batching - now enqueues its
(kind, lifetimeId) on the deferred destroy queue and the GL thread replays
the DestroyAndFree helper at the verb hooks (CopyImageLayeredScenario aborted
Fatal{RoleViolation, MGPipeSlots} without it).
Doorbell::Wait's spin reads the clock once per 64 yields: on the workload
device a steady_clock read is a real syscall (no vDSO) and the per-iteration
reads were ~20% of the whole process at ~1000 waits/frame.
Gates: gen_pipe --check/--self-test green; unit 2188/2188;
integration-split 110/111, the one red being GenerateMipmapDepthPixels whose
DirectGLES and DirectVulkan MONOLITH siblings flake identically with every
code path of this package provably inert (PushIsArmed false, no ClientSession,
deferral's monolith early-out).
A split-only verb-handle workspace in MGPipeApplierState (written by the sinks before
dispatch, read by the backend within the same verb) carries the record's handles to the
backend entries whose shared function-table signatures cannot grow them without moving
G1. OnBlit resolves ReadFbo/DrawFbo through the FBO twin table by handle (the named arm
of BlitFramebuffer, a loud refusal when the backend does not consume it) and the split
path's ScopedBlitBindings rebinding is deleted; CopyTex resolves MGPCopyFromFramebuffer::Dst;
the mip descriptor check resolves MGPMipPlan::Res; the indirect families resolve their
buffer handles from the records and read the bytes from the server staged shadow with
RequireStagedCoverage. EnsureBufferResourceForHandle answers HasDefinedContent from
Desc.HasDefinedContent OR the staged coverage set (the streaming idiom defines content
through subdata with no new descriptor). The minting GetOrCreate(StatePtr), HandleOf and
the death switch's lifetime-id arms take Fatal{RoleViolation, MGPipeSlots} from the apply
thread, as do the three MGPipeSlots() entry points; the frontend BufferObject legacy
accessors take Fatal{RoleViolation, buffer-legacy-arm}; Magma's four caps reads go to the
server's own backend and the client-mirror fallback arm is a named refusal.
Evidence: unit 2156/2156 (incl. 9 new RemoteF1 + 10 new RemoteGuards death tests pinning
the four Fatal names); monolith smoke green; six production mutations each turned their
named test red and were restored; c1f_redcheck M5 RED then RESTORED GREEN. The mip
descriptor check overlaps tx and is coordinated at the merge (tx's sync rekey wins the
sync body, this handle resolution wins the identity check). CONTRACT-P5C section 3.