Breaks the one process = one server = one session = one context equation
that the whole stack's correctness used to rest on. Design and per-slice
shape: docs/Disaggregated/design/11-state-ownership.md.
S1 wire: bind_context ring record (opcode 84) announces the current
context at the make-current edge; CreateContext/DestroyContext join the
SurfaceOp control frames; control revision 5 -> 6; the server keeps a
per-session token table and refuses unknown tokens by name.
S2 server: SessionRuntime per session with thread-scoped resolution
(singletons become fallbacks), per-thread gPipeInputs, per-session latch
domains, per-thread segment resolvers; the in-process unix supervisor
serves up to MOBILEGL_IPC_INPROC_MAX_SESSIONS (16) concurrent sessions;
surface mode is declared per session and the display lease refuses a
second on-screen session by name.
S3 frontend: ShareGroupState owns the shared object stores and name
generators (buffer/texture/renderbuffer/sampler/program), GLContext keeps
per-context bindings/errors/container objects; pGLContext becomes
thread-local with a process default context preserving the single-context
world verbatim; eglCreateContext builds a real GLContext and joins the
sharing context's group.
S4 backend: native EGL tuples keyed by (session, context token);
InitDisplayAndContext's unconditional teardown is gone - creating a
second context or surface no longer destroys the first; eglCreateContext
sharing is wired to the native driver; bind_context is the native switch
point.
S5 applier: MGPipeApplierState splits into MGPipeObjectRecords (per
share group) and MGPipeWorkingState (per context), resolved through a
{session, token} registry with a thread-local cached fast path.
Tests: MultiSessionTest (incl. two sessions x two contexts interleaved
with real clear/readback witnesses), ApplierOwnershipTest (with a
null-resolver negative control), ContextStateOwnershipTest,
ServerLoopEglTest native-context liveness; 99/99 of the acceptance set
green on the host build.
Known debts (documented in the design doc): twin registries still keyed
by handle alone, no per-record ContextSlot, process-global backend
generations, MakeCurrent dedup per current binding.
Steps 1 and 2 of the plan the design review set out, landed and measured on the
device. Step 1 answers the question step 2 depended on; step 2 answers the one the
whole line depended on, and the answer is that the window's shape is wrong.
STEP 1 - CAN AN ANSWER BE LOST IN DELIVERY? NO. ReplyPool now counts what the link
took (PostedReplies) and what it refused (FailedReplies), and a non-OK result is
NAMED instead of swallowed: it checked only BUFFER_TOO_SMALL and let
TRANSPORT_CLOSED vanish with no line and no counter, so "the server never sent it"
and "it was sent and the buffer overwrote it" were the same symptom with opposite
fixes. Measured: posted=4501 failed=0. The twenty-answer gap between the server's
tally and the client's was a sampling difference, not a drop - and the server's
m_answered counts ATTEMPTS (incremented before PostReply), which is why it could
never have settled it.
STEP 2 - DOES A RESIDENCY BUDGET FIX THE WINDOW? NO.
Wire_ResourceCreate now measures how many reply-owning records the client has
emitted since the oldest window entry was pushed (ClientSession::ReplyPostings, a
new clock) and takes the oldest answer back as soon as that approaches the reply
buffer's depth, instead of deferring past the point where the answer can still
exist. On the device:
P12win call=4501 cfg=2 eff=2 pending=2 taken=63 pushed=65 blocked=4435
budget=4451 readfail=8872 posts=54377
kind=frame-op frame=14 wait_replies=9078 by_op=2=9071 wall 27.87 s
budget=4451: it fires on essentially EVERY create, the window stops deferring
(pushed=65), and the drain still reads nothing back. THE REASON IS STRUCTURAL, not
a constant:
* the window only gets control WHEN A CREATE ARRIVES. Between two creates this
client emits on the order of a thousand records - at the FIRST failed read the
reply ring already spanned [1175..1504] while the head was seq 310 - and nothing
looks at the window in that gap;
* what displaces a deferred answer is not what this client emits, it is WHAT THE
SERVER ANSWERS. The server is the one behind (one answer per ~5 ms wakeup while
the client runs ahead), so the replies arriving during the gap are its LATE
ANSWERS TO THE EARLIER CREATES - they fill the 8 slots first;
* so the check fires too late by construction. Tightening it does not fix that; it
only removes the deferral entirely, which is what pushed=65 is.
WHAT THAT LEAVES. A deferral is readable only if the client KEEPS the answers it
deferred. The buffer is bounded by COUNT and the client cannot bound how many
answers the server will post between two of its creates, so hoping the buffer still
holds one is a race the client loses - which is also why the earlier two diagnoses
(reply-pool churn, then FIFO-with-no-way-out) were both describing symptoms of the
same thing. The shape that can work is retention: the reader keeps the answers for
the seqs still outstanding (a handful), drops them when read, and reports a LOSS
when a read asks for one it no longer has. That is a transport change - StreamLink's
ring becomes a retention map with a cap and a lost counter - and it is the next step.
BOTH STEPS STAY: step 1 is a net gain on its own, and step 2 is the safe direction
(it can only degrade the window to no-deferral, i.e. the pre-window shape) and its
clock is the one the retention design needs. MOBILEGL_IPC_CREATE_WINDOW stays at 1.
172/172 and 13/13 after the probes were removed. The measurements and the revised
conclusion are in notes/p12/CREATE-WINDOW-MEASURED.md section 9.
- MG_Pipe/MGPipeTypes.h gains inline MGPipeNeutralReadPixelsPack() next to MGPPixelPackState, whose owner it is; the applier that installs the neutral state and the comparator whose oracle it is now call the same function
- MG_Remote/Server/PipeApplier.cpp read_pixels takes the constant from there instead of building it field by field
- MG_Impl/Pipe/PipeFill.cpp drops ServerReadPixelsNeutralPack, the re-typed second copy: a drift between two hand-typed spellings costs a FALSE DIVERGENCE on the server read, not a build break, which is the direction that fails quietly
- notes/p7/verify-split.md 2.2 says one spelling and why the old reasoning (do not reach into MG_Remote/Server from MG_Impl) did not survive the failure-direction argument
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.
- The integrator's census over the merged tree left two markers nothing admitted, both
DirectGLES.Split.ClientVertexArrayScenario: GetBoundVertexArray@DrawArrays. Disjunct 1 fails
(draw_vbo is kWaitNone) and disjunct 2 fails (that row's retiring phase names P5e, correctly,
for the ORDINARY draw path vi and mv retired). But these two entries pull the pair on a record
the client IS parked behind: MGPipeBarriered escalates a draw carrying client vertex arrays
(ID-83), ID-82 puts client arrays outside P5e entirely, and the read site
(SyncClientSideVertexArraysForDrawArrays) aborts by its own name if it is ever applied
unbarriered. The pull is legal; no static table could see it.
- Third disjunct: the record was barriered BY ESCALATION, i.e. wireSaysBarriered(op, payload,
applierState) && MGPipeWaitClassFor(op) == kWaitNone. Both halves were already computed on
every record - ApplyOne computes the predicate unconditionally for ID-103's reason - so this is
one more bit stamped beside the barriered stamp, not new plumbing.
- It is a SEPARATE flag rather than a refinement of the barriered stamp, because the two answer
different questions: the stamp answers "is the client parked" (the allocator and registry
guards ask that), this answers "and for a reason the static table cannot see" (only the strict
knob asks). Its default is false - the answer that admits nothing - which is the opposite of
the stamp's default and deliberately so.
- The marker says WHICH disjunct: ADMITTED when the generated table answered, ADMITTED-ESCALATED
when only the record did. That keeps the lane's comparison against --print-admitted exact
instead of widening the static list with every pair that could ever escalate - and the tag sits
in the slot the grammar already has for the reason, so Fatal{UnmigratedPipeInput filters are
untouched.
- Escalation also happens to be the truer justification for GetTransformFeedbackProgram@DrawArrays,
which ID-125 admits by phase. Both now hold; ID-125's disjunct is kept, as ruled.
- Red-once: AnEscalatedRecordsPullIsAdmittedAndSaysWhy. Dropping `&& !escalated` from the strict
arm kills it - "FieldOwnershipTest.cpp:938: Failure / Actual: false / signal 6" with
'Fatal{UnmigratedPipeInput, "GetBoundVertexArray@DrawArrays"} [BARRIER-PULLED,
MOBILEGL_IPC_STRICT_ERRORS=1, retires in P5e (Espryt unbarriered), P7 (Magma)]'. Its control is
StrictErrorsTurnsABarrierPulledReadIntoANamedAbort, which drives the identical pull with the
flag at its default and must keep aborting.
- PrepareForDraw's single GetProgramForDraw() and PrepareForCompute's
GetProgramForDispatch() are the 68 red entries this package owns (61 + 7 in the
base census; 62 + 7 once the PersistentMapArm entry is re-run under its own lane
environment). Merely CALLING the accessor is what trips MGP_INPUT_CHECK, so the
row retires by not making the call.
- DrawProgramFromRecords() states the arm ONCE and as a CONJUNCTION:
ProgramHandleArm() AND TextureImpl::UnitTexturesByHandle(). The hoisted value
serves four callees and the arm has to name every one of them - the second
conjunct is BindCurrentTextures', whose legacy memo key and whose
sampledTargetForUnit lambda both read a real object. The family bits are
independent A/Bs, so the
- ApplyOne stamped `kMGPipeP5eClientWaitRuleLanded ? wireSaysBarriered : true`, which reads a
build constant and never asks which server this is. kCapRunAheadApply is never published on
DirectVulkan (ID-90), yet draw_vbo / blit / clear / launch_grid are all kWaitNone - so the day
the constant flips, a Magma server would stamp every draw record UNBARRIERED while its client
is still lockstep. CountBarrierPull is an unconditional Fatal on an unbarriered pull, no knob
involved, so Magma dies on its first draw and MagmaP7AllocatorDebtScope's exemption goes with it.
- The stamp is now `MGPipeApplierStampsBarriered(waitRuleLanded, serverPublishesRunAhead, wireSaysBarriered)`,
a pure function beside the applier, so ID-90 is something a unit case can hold AT THE POST-FLIP
value of the constant rather than a claim that first becomes testable on integration day. That
is MGPipeRunAheadCapBitsFor's shape, for MGPipeRunAheadCapBitsFor's reason.
- Both predicates stay computed unconditionally, as arguments rather than short-circuit arms:
ID-103's reason, extended to the capability probe.
- `ServerPublishesRunAhead` is promoted out of the anonymous namespace as
`MGPipeServerPublishesRunAhead` (body unchanged) so the red-once can state "this session lacks
bit 10" in its own words instead of inferring it from the stamp under test (ID-102).
- Red-once: ADrawOnASessionWithoutTheRunAheadBitStaysStampedBarriered drives a real DrawVbo
through ApplyOne on a SetCapabilityBits(0) session. Deleting the new conjunct makes the
post-flip assertion false - "RemoteClientControls.inc:143: Failure / Expected: true / exited 124".
- 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.
- SyncTextureToBackendByHandle and SyncMipmapsToBackendByHandle get their bodies
(the c0e seams): record first, twin by GetOrCreateByHandle, three syncs from the
record, no frontend touch and no allocator probe on the path.
- BackendTextureObject carries m_pushedSyncHandle (CONTRACT-P5E §4.1's m_handle), which
is what the three sync prologues read instead of probing the client allocator with
HandleOf, and what selects the by-handle arm inside them.
- IsDrawSyncCleanByRecord replaces IsDrawSyncClean(frontend) on the memo-hit path: the
same conjunction over the record's serials and the twin's own, with BOTH halves of each
resync pair read and neither clearing the other's (D-E2, scout R4).
- The unit work list is built from st.BoundSamplerViews[] and keyed on
(ContextSerial, SamplerViewsSerial, window, backend context generation); PairingsIntact
is not carried on it, because a {slot, gen} has no stale-pairing hazard to catch.
- ResolveAndBindUnitTextures is one pass over the window with the target from the view
CSO, then an unbind driven by g_boundTexturesCache; the alias arbiter dies (the client
arbitrated, SamplerEmit.h).
- BindCurrentTextures' memo key is ruling 6's both halves, entry selection included.
- The sampler pass reads BoundSamplerStates / the built-in sampler CSO / the view's
InternalFormat; the apply-thread mint and ResolveUnitSamplerBackend become a named
refusal under a transport (§4.2).
- GenerateMipmap resolves VerbMipRes and never the active unit; the detach sweep is keyed
by the texture handle over the applier's framebuffer records. The row is NOT flipped to
kWaitNone: the RGB16F/RGB32F CPU filter still reaches the client's level shadows, so
ruling 14's escape applies and the flip is trailing.
- RequireImageBindableStorage gets a by-handle entry whose re-dirty is
Fatal{UnmigratedEmulation, "image-bindable-redirty"} at the entry rather than three
frames deep at a MarkStorageDirty guard.
- The buffer-texture arm resolves scout R5 with Desc.BufferForTexBuffer / BufOffset /
BufSize and EnsureBufferResourceForHandle(nullptr, h) - that function is client-free
under an active transport (kimi audit row 29).
- CheckUnitWindows(st) at draw and dispatch makes the §5.3 window promise checkable and
checked: narrower than [0, MaxTouchedTextureUnit] is
Fatal{ProtocolCorruption, "SetSamplerViews.Count"} / "BindSamplerStates.Count".
- MipmapStorage's guard list gains the read-only shape accessors P5c deliberately
exempted, now that nothing on the draw path reads them.
- c0e and id were implemented in parallel and each declared the two shared seams,
so the merge produced five duplicate definitions the text could not collide:
MGPipeBarriered, MGPipeApplierCurrentRecordIsBarriered and the three by-handle
twin resolvers. Resolved per ID-99 and ID-103.
- MGPipeBarriered keeps c0e's real body (the static wait-class column, the XFB
escalation and the client-array escalation); id's constant-true placeholder goes.
- The current-record flag keeps c0e's thread_local storage and setter and loses
id's MGPipeApplierState member: it describes which record THIS THREAD is inside,
not per-context state the applier owns, and the allocator guard that reads it
would otherwise have to find the right context's applier first. id's test hook
posts through the setter.
- The three resolvers keep id's real bodies beside each registry; c0e's asserting
stubs for them go. The seam stubs no package has bodied yet stay.
- ApplyOne stamps `true` while kMGPipeP5eClientWaitRuleLanded is false. The
predicate says what the client WILL do; until ra changes EmitAndWaitTails the
client still blocks after every record, so a `false` stamp would withdraw the
§4.4 exemption from a probe the client's own wait still makes safe. The
predicate is computed on every record regardless, so it is exercised for the
whole phase instead of first running on the day it starts deciding.
- Gates on the merged tree: unit 2206/2206, integration-split 111/111, both
generators --check/--self-test green, include closure 0 problems, dirty surface
rc 0, doc citations one pre-existing CONTRACT-P5.md line (c0e retired another).
- CONTRACT-P5E §2.1's predicate gets its two names in MG_Pipe/PipeApply.h:
MGPipeBarriered(op, payload, st) and MGPipeApplierCurrentRecordIsBarriered(),
with MGPipeApplierState::CurrentRecordBarriered as the per-record flag that
PipeApplier::ApplyOne stamps before every decode. The body answers TRUE for
every record until ra lands the wait rule, so lockstep is unchanged and this
package alters no behaviour; ra rebases onto that line rather than adding it.
Package c0e owns the final declaration of both names - the overlap is one hunk.
- §4.4: MGPipeRefuseAllocatorFromApplyThread now exempts a probe only inside a
named scope AND while the record being applied is barriered. The client's wait
is the whole of what makes an apply-thread frontend probe safe; behind an
unbarriered record the allocator's free list and lifetimeId -> slot map are
moving under the read, so the value is wrong by construction - there is no
"count it" arm and MOBILEGL_IPC_STRICT_ERRORS is not consulted.
- MGPipeApplierIsUnbarrieredApply() and
MGPipeRefuseFrontendKeyedRegistryFromUnbarrieredApply() are the non-allocator
half of the same rule, for the surfaces that answer from a frontend SharedPtr
without calling the allocator (SlotTables.h's state note and its reader).
- Ruling 12: MGPipeReverseAnnouncementScope -> MagmaP7AllocatorDebtScope, keyed
on MG_Config::ActiveBackendType == DirectVulkan. Magma's four apply-thread
allocator debts move onto it (VulkanRenderer's two hidden-resource shutdowns
and the named-blit endpoint resolve, ResourceTracker's GPU-write
announcement); an Espryt probe can no longer borrow Magma's exemption by
constructing its scope.
- BufferImpl::HandleOfBuffer is Espryt's debt, not Magma's, so it moves to
MGPipeFrontendKeyedRegistryScope - which the Magma-keyed scope would not have
exempted at all. It retires when vi/sb carry the handle into
EnsureBufferResourceForHandle / IsBufferDrawCleanByHandle.
set_context_values (opcode 79, MGPContextValues 96B, kCtxState) carries the active texture
unit, the max touched unit, the fifteen touched-binding-point counts and the five value-class
XFB rows, emitted at validate under the subsystem bit and hash-suppressed as a whole; the
emit gate and the residual-skip gate read the same answer so they can never disagree.
MGPipeApplySetContextValues writes gPipeInputs (the PackState shape). MGPAttribValue grows
24->56B to carry the frontend's converted three views, and MGPipeApplySetVertexAttribDefaults
finally writes them (the 'ignores MGPAttribValue::ValueClass' warning dies), so
GetCurrentVertexAttribute leaves the refusal set. The three texture shutters answer the
applier's own serials under a server-stamped verb (APPLIER_DERIVED), storage answer kept
verbatim elsewhere. FieldOwnership.def: nine value-class rows become derived RECORD_SUPPLIED,
the three shutters APPLIER_DERIVED, the nine object-class rows keep their phases;
FieldOwnershipTest pins that the remaining pulls are exactly the object-class list.
gen_pipe.py gains SCAN_EXEMPT_ACCESSORS with per-name reasons and retiring phases (it repairs
the --check gate the hd merge's two G6 probes had left red).
Evidence: unit 2175/2175; integration-split 111/111; both generators' --check and
--self-test green; rsp on the Xfb capture scenario 36 -> 22; red-once - gating the emission
off turns seven named scenarios red, restoring returns 111/111. CONTRACT-P5C section 5.3.