Files
MobileGL/docs/Disaggregated/notes/p34b/espryt-d3.md
T
swung0x48 22761a692d [MG_Pipe, MG_Backend, MG_Remote, docs] (P7): ID-P7-30 - D3's two review fixes: the bit-8 and bit-13 rows are pinned, and deletion ordering is guaranteed by the Live/Gen test
The vertex-input and buffer-bindings rows of SubsystemDeps.def became live
behaviour when every reader started reading the table; two static_asserts
now pin them the way the texture and programs rows already were. The
"safe by construction" story about destroy ordering was inverted for the
case where a view holds its owner's last reference - the owner's destroy
reaches the wire first - so the three places that told it now say that the
walk's Live/Gen test is the guarantee.
2026-09-22 18:27:43 -04:00

22 KiB

espryt D3 — the Espryt (DirectGLES) stream, part 3

The defect package D2 found and could not fix, plus the two D-K2 loose ends D2 left for whoever owned MG_Backend/DirectGLES/ next. Base p7/espryt-d2 @ a7c06f4f, worktree ~/w7/p7-espryt-d3, branch p7/espryt-d3.

Per-slice red-once notes (R-16 / rule J). A slice is not done until the case it turns on has been seen RED against the tree without that slice's fix and GREEN with it, on the arms the fix is for — and for this package that means the two-process arms, not only inproc.

Four commits, in cherry-pick order:

# files what is true after it
1 MG_Test the pull build compiles again — SubsystemDepsTest's server half is MOBILEGL_PIPE_PUSH-only
2 MG_Backend, MG_IntegrationTest, MG_Remote, docs an owner-side upload shows through a sampled texture view, because the split clean gate resolves to the storage record
3 MG_Backend, MG_Pipe, MG_Impl D-K2's dependency rule has one statement, and every reader reads it
4 docs this note, and the census row the fix falsified

Commit 1 is not a deliverable and is first for a reason: without it ninja -C build-linux exits 1 at D2's head, so every later commit would be measured on a tree whose pull build does not build.


slice 0 — the pull build compiles again

Not a deliverable; a prerequisite for this package's own G1, and worth stating first because it means D2's head does not pass gate C as it stands.

D2's new MG_Test/Backend/DirectGLES/SubsystemDepsTest.cpp guards its CLIENT case with #if MOBILEGL_PIPE_PUSH + GTEST_SKIP and does not guard its two SERVER cases, which take the addresses of ResolveFramebufferSubsystemArm / ResolveTextureResourceSubsystemArm / ResolveSamplerSubsystemArm / ResolveProgramSubsystemArm. Managers.h declares all four inside #if MOBILEGL_PIPE_PUSH and Managers.cpp defines them inside it, so with the pull flags the file does not compile:

../MobileGL/MG_Test/Backend/DirectGLES/SubsystemDepsTest.cpp:200:11:
  error: use of undeclared identifier 'ResolveFramebufferSubsystemArm'
... 9 errors generated.
ninja: build stopped: subcommand failed.

libMobileGL.so has already linked by then, so G1's .text and symbol numbers are still readable — but ~/w7/logs/p7-gate-c-inner.sh reports === linux build rc= === and it was 1. The two cases get the same guard and the same skip the client case already carries. Nothing else moves; ninja -C build-linux rc 0.


slice 1 — an owner-side upload is visible through a sampled view

The hole

TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary, registered by D2 and excluded by name from the three split arms with the cause in the CMake comment: once a glTextureView has been SAMPLED, a later glTexSubImage through the OWNER's own name never becomes visible through the view. Red on inproc, spawn and tcp; green under monolith.

The mechanism, end to end:

  • A view owns no texels, and the client keeps one emission cursor per storage. An upload through a view's own name is remapped onto its storage owner before it is emitted (MG_Impl/Pipe/TextureEmit.h, MGPipeTextureEmitter; TextureEmitTest.cpp's AnUploadThroughAViewKeysOnTheStorageOwner pins it). So every resource_subdata for either name is keyed on the OWNER.
  • MG_Pipe/PipeApply.cpp's ApplyTextureUpload accumulates into that record and does ++stored->Serial on it — the owner's. Nothing moves the view's.
  • BackendTextureObject::IsDrawSyncCleanByRecord (MG_Backend/DirectGLES/Managers.cpp) read record.Serial and record.PendingUploads of the record it was handed, which for a bound view is the view's. A view's Serial is moved by nothing an upload does, so after the view's first sample the gate answered CLEAN forever and SyncTextureViewToBackendByRecord — the only thing that drains the owner's pending uploads on a view's behalf, because it syncs the storage twin by handle at its head — never ran again.
  • Under monolith the same gate reads GetContentVersion() THROUGH the view, which TextureObjectView forwards to its owner (TextureObjectView.cpp:100-102). The split gate had no equivalent of that forwarding.
  • CONTRACT-P5E.md §5.2 stated the split gate in full, rec->Serial and all, so the code was faithful to a wrong contract. The contract is amended, not restated.

The client half was never at fault, and the red run proves it rather than arguing it: the case's own MGPipeTextureEmitter::SubDataCount() / RefusedSubDataCount() / DrainListSize() assertions PASS on the red run. Only the pixel channel is red.

The decision: resolve the predicate, not index the reverse edge

Chosen (a): the gate's two STORAGE clauses resolve through Desc.ViewOf to the storage record. PipeTextureStorageRecordForRecord (new, Managers.cpp, beside PipeTextureRecordForHandle) answers with the record itself for a texture that owns its texels and with the record Desc.ViewOf names for a view. IsDrawSyncCleanByRecord asks it for Serial and PendingUploads; the three PARAMETER clauses and Desc.StorageKind stay the view's own record's, because a view has its own texture parameters and its own built-in sampler and that is the whole reason the second name exists. SyncTextureViewToBackendByRecord stamps that same storage serial at both its arms — the steady one and the one after the native view call — so the stamp and the gate name one quantity rather than two.

Rejected (b): a reverse index owner → views, so an owner-side resource_subdata bumps every viewing record's Serial. Three reasons, in increasing order of how much they would have cost to discover later:

  1. It puts new mutable state in MG_Pipe's applier, which BOTH backends read, to answer a question one backend's gate asks. Magma does not need it (below).
  2. It makes ApplyTextureUpload write a record other than the one the call names. The applier's serial discipline is stated in that function's own comment — the twin stamps its synced serial from inside the sync that reads THIS record — and a bump arriving from a different record's apply is exactly the shape that comment exists to forbid.
  3. Deletion ordering, which is what actually decided it. The index has to be pruned when a VIEW dies, or an owner upload bumps a slot that has since been recycled to an unrelated texture — a silent cross-texture serial skew with no assertion anywhere that would catch it. The resolve direction needs no pruning at all, and it is checked at every use: PipeTextureRecordForHandle tests Live and Gen, so a recycled slot answers null rather than a stranger's record.

Deletion ordering for the chosen shape: the walk's Live/Gen test is the guarantee, not the emit order (the review corrected an earlier draft of this paragraph that claimed the ordering alone was enough). A view may outlive its owner's GL NAME — glDeleteTextures(owner) while the view is alive is legal and the storage must survive — and it cannot outlive the owner's STORAGE: TextureObjectView::m_storageOwner is a strong SharedPtr, and MGPipeEmitTextureDestroyAndFree is called from TextureObjectBase's DESTRUCTOR, whose own comment says why — "the last SharedPtr to this object dropping, not the glDelete that only marks the name and leaves a still-bound object very much alive"* (TextureObject.cpp:63). But m_storageOwner is a member of the derived class and there is no user destructor on the view, so when the view holds the owner's last reference C++ destroys the member first: the wire carries resource_destroy(owner) and then resource_destroy(view), and for exactly one apply the view record is live with Desc.ViewOf naming a freed slot. No draw can land between two destroys from one destructor chain, and the walk tests Live/Gen, so that window answers null — and a null answer is NOT clean, the direction that re-syncs, never the direction that shows stale texels. It is not a refusal and raises nothing (rule I: no new abort site, no new Fatal family word).

Views of views resolve transitively. One hop always reaches storage in practice — glTextureView composes a view-of-a-view onto the ROOT at creation, which is what the spec's additive min-level rule means; the client's emitter says so in as many words (TextureEmit.h EmitResourceRespecify) and TextureObjectView's own invariant is that its owner "is never a view itself". The walk is written transitively anyway and BOUNDED, because this side may not depend on a client invariant to terminate: a chain the wire could forge into a cycle ends as a null answer rather than as a hang.

The tree already agreed with (a), on the other backend. VkTextureManager:: ResolveWireTextureStorage (MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp:2603) is the same walk — follow Desc.ViewOf, bound the hops, check Live/Gen, return null on failure — and its comment states the same two reasons: "Production views point directly to their root. The bound also makes a corrupt or future multi-hop record graph terminate without ever following client data." Magma therefore never had this defect: it resolves the storage at every use instead of memoising a per-view serial. Picking (b) would have given the two backends two different answers to one question.

Red-once (rule J)

At D2's head a7c06f4f, with the CMake exclusion lifted and nothing else changed:

$ ctest -R 'DirectGLES\.(Split|Spawn|Tcp)\.TextureViewAliasScenario' -j 3
79% tests passed, 3 tests failed out of 14

The following tests FAILED:
   4042 - DirectGLES.Split.TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary (Failed) integration-gpu integration-split
   4046 - DirectGLES.Spawn.TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary (Failed) integration-gpu integration-spawn
   4050 - DirectGLES.Tcp.TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary (Failed) integration-gpu integration-tcp

and the failure itself, verbatim from the Split entry:

../MobileGL/MG_IntegrationTest/Scenarios/TextureViewAliasScenario.cpp:328: Failure
the first owner write, through the view: 11408 of 11408 pixels disagree;
  first at (2, 2) is rgba(50,160,30,255), expected rgba(0,0,255,255) +/- 2
../MobileGL/MG_IntegrationTest/Scenarios/TextureViewAliasScenario.cpp:328: Failure
the second owner write, through the view: 11408 of 11408 pixels disagree;
  first at (2, 2) is rgba(50,160,30,255), expected rgba(255,255,0,255) +/- 2

rgba(50,160,30,255) is the LAYER SEED — what the view was minted holding. Both writes are lost, not one, which is the signature of a gate that latched rather than of a record that got dropped. The drain's counter assertions are silent in the failure list: they passed.

The MONOLITH control in the same tree is 4/4:

$ ctest -R 'DirectGLES\.TextureViewAliasScenario'
100% tests passed, 0 tests failed out of 4

With the fix, the same invocation on the three arms:

$ ctest -R 'DirectGLES\.(Split|Spawn|Tcp)\.TextureViewAliasScenario' -j 3
100% tests passed, 0 tests failed out of 14

Rule J is satisfied on spawn AND tcp, not only inproc: entries 4046 and 4050 are the two-process arms and both went red and then green.


slice 2 — D2's D-K2 loose ends, now in-partition

D1 has landed on feat/disaggregated, so MG_Backend/DirectGLES/ is free and the two items D2 listed as the integrator's are taken here.

The stale prose in the header of the file that implements the refusal

MG_Backend/DirectGLES/Managers.h's resolver block said "THREE OF THEM CARRY A DEPENDENCY" (there are six rows; bits 8 and 13 were the two it did not know about) and listed "10 without 11" among the mirror pairs that are FINE — when 10-without-11 is precisely D-K2's fourth row (ID-14/ID-15), refused twenty lines below by ResolveTextureResourceSubsystemArm and withheld by the client. MGPipe.h lost the identical pair of defects with R-5; this copy was left behind because the file belonged to a package in flight. It does not restate the rows again: the file that states them is the file the code reads.

Both readers read the table

More than the two one-line changes D2 estimated, because the rule had six executable and prose statements and the estimate counted two:

reader was now
MG_Impl/Pipe/PipeFill.cpp P4aFamilyDependencyBits its own kMGPipeP4aFamilyDependencies[], four rows MGPipeSubsystemRequires(); the local table is deleted
MG_Impl/Pipe/PipeFill.cpp P5eFamilyIsLive hand-coded (pushMask & kMGPipeSubsystemResources) == 0 MGPipeSubsystemDependenciesAreSet(bit 13, mask)
MG_Backend/DirectGLES/Managers.cpp PipeSubsystemDependencyMissing took a literal dependency BIT per call site takes the FAMILY and asks the table
…its Resolve<Family>SubsystemArm call sites four calls with four hand-written sentences (ResolveProgramSubsystemArm never called it) three calls, one per family that has a row, naming only the family
MG_Backend/DirectGLES/Managers.cpp ResolveVertexInputSubsystemArm hand-rolled bitSet && !resourcesBitSet the table
MG_Backend/DirectGLES/DirectGLES.cpp ResolveBufferBindingSubsystemArm hand-rolled bitSet && !resourcesBitSet the table

MGPipe.h gains one accessor, MGPipeSubsystemDependencyWhy(), so a reader that refuses prints the ROW'S OWN sentence instead of a copy of it — the .def has carried a Why column since R-5 and nothing read it.

One behaviour improvement falls out of it rather than being designed in. The texture family is the only row with two required bits, and it used to be two guarded calls here, so a mask missing bit 11 but carrying bit 7 was reported with the bit-7 sentence. One call now computes required & ~mask from the row, so the refusal names whichever bit is actually missing.

What this does to the anti-drift property, said out loud because it is a real weakening. SubsystemDepsTest used to compare two COPIES of the rule; it now compares two READERS of one table, which cannot catch a typo in a row. It is kept, and the .def's prose now says why: the two sides still fold in reasons of their own — the server's own family bit and D-C3's wire-caps refusal, the client's consumer and wired conjuncts — and those can still disagree at a mask. The compile-time half of D2's red-once (drop the fourth row from the .def → MGPipe.h:225/:230 fail) is untouched and is now the load-bearing one.

SubsystemDepsTest 5/5 after the switch, including D2's two refusal-lane cases at 0x1fff and 0x7ff.


Gates

Measured on p7/espryt-d3 @ HEAD, build-split configured by p7_worktree.sh, build-linux configured with the same compilers and DISAGGREGATED=OFF PIPE_PUSH=OFF PIPE_LEGACY_MEMOS=ON to match ~/w7/pipe/build-linux.

G1 — the pull build is byte-identical

value
readelf -S -W build-linux/libMobileGL.so .text 0xa52203 (required 0xa52203)
nm --defined-only symbols 30570
vs ~/w7/p7-before/pull-syms.txt 0 added / 0 removed
nm --defined-only -D vs pull-dynsyms.txt 0 added / 0 removed
ninja -C build-linux rc 0 (rc 1 at base — slice 0)

Measured three times: after slice 1, after slice 0, and after slice 2. Identical every time.

Why nothing this package touches reaches the pull build, per file:

file where the change sits
Managers.cpp PipeTextureStorageRecordForRecord, PipeSubsystemDependencyMissing, the four resolvers, ResolveVertexInputSubsystemArm inside #if MOBILEGL_PIPE_PUSH
Managers.cpp IsDrawSyncCleanByRecord inside #if MOBILEGL_PIPE_PUSH
Managers.cpp SyncTextureViewToBackendByRecord inside #if MOBILEGL_BUILD_DISAGGREGATED
Managers.h the declaration and the prose inside #if MOBILEGL_PIPE_PUSH
DirectGLES.cpp ResolveBufferBindingSubsystemArm inside #if MOBILEGL_BUILD_DISAGGREGATED
MGPipe.h MGPipeSubsystemDependencyWhy inline constexpr, unreferenced in the pull build
PipeFill.cpp MOBILEGL_PIPE_PUSH-only translation unit content
SubsystemDepsTest.cpp, MG_IntegrationTest/CMakeLists.txt, the two .md files not libMobileGL.so

The numbers are the proof; the table is what makes them checkable.

The three scripts

python3 scripts/ci/fatal_census.py                        rc 0
  79 abort sites over 20 files of the split server image, 44 distinct family words,
  3 refusal words, 0 unmarked          (79 is D1's number; this package adds none — rule I)
python3 scripts/link_ratchet.py --build-dir build-split \
  --baseline scripts/data/link_ratchet_baseline.txt --assert-monotone    rc 0
  ratchet: unchanged at 186 symbol(s)
python3 scripts/ci/spawn_lane_parity.py build-split       rc 0

Lanes

All five at 100%, each run on its own so that "The following tests FAILED" belongs to the lane that printed it:

lane base (a7c06f4f) D3 delta
unit 2418 2418 / 2418 0
integration-split 263 264 / 264 +1
integration-spawn 179 180 / 180 +1
integration-tcp 182 183 / 183 +1
integration-magma-split 75 75 / 75 0

Names only grow, and the growth is named. The base column is a real ctest -N -L taken on this worktree with MG_IntegrationTest/CMakeLists.txt reverted to a7c06f4f and nothing else, not an arithmetic guess. Set difference, per lane:

unit                     added=0 removed=0
integration-split        added=1 removed=0
    + DirectGLES.Split.TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary
integration-spawn        added=1 removed=0
    + DirectGLES.Spawn.TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary
integration-tcp          added=1 removed=0
    + DirectGLES.Tcp.TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary
integration-magma-split  added=0 removed=0

Three names, one per arm, and they are the un-exclusion. Nothing was removed from any lane.

The DirectVulkan arms of the same scenario: 30 / 30. ctest -R 'DirectVulkan\..*TextureViewAliasScenario' covers the monolith entry plus the six informational Magma tiers (integration-magma-{all,full}-{split,spawn,tcp}), four cases each, and every one passes on this head — as they did at base. Magma never had this defect, because VkTextureManager::ResolveWireTextureStorage resolves the storage at every use instead of memoising a per-view serial. They are informational, not gating: integration-magma-split (75) does not carry this scenario.

One tcp-lane flake, already on record and not this package's. The FIRST full gate run had integration-tcp at 182/183; the lane is clean on re-run and clean in the final run above. split-scenario-census.md's own "SampledSetStalenessScenario on tcp" section records the same shape from D2's measurements and attributes it to the tcp lane serialising through one supervisor (RESOURCE_LOCK mobilegl-tcp) that serves one session at a time. Recorded here so the count is not quietly a best-of-two.


What remains

1. KHR-GL46.texture_view.coherency has no SPLIT reading. It is exactly slice 1's test (tools/cts/caselists/p7-texture-gl46.txt:1050), and the device-window baseline confirms D2's claim case by case — report-texture.json's results map has "KHR-GL46.texture_view.coherency": "Pass". That is a monolith reading; 門 5's split comparison is the device window's, so on host hardware the integration case is the only thing standing behind this fix. When the window runs, texture_view.coherency is the row to read first. Two corrections to the citation while it is being read:

  • the block is KHR-GL46, not KHR-GL43 as espryt-d2.md and split-scenario-census.md write it. The caselist, the baseline and CONTRACT-P7.md §7.3 all say GL46;
  • texture_view is 1/7 fail in that baseline (report-texture.txt:24) and the failing case is KHR-GL46.texture_view.view_sampling, NOT coherency. It is a pre-existing monolith Magma conformance failure, on the list CTS-base/README.md calls "monolith Magma 的既有一致性 状态", and it is not this defect — worth naming so that a 6/7 reading after the fix is not mistaken for a regression this package caused.

2. The Magma arms of TextureViewAliasScenario are informational, not gating. Magma has no defect to fix, but nothing in integration-magma-split watches the property on that backend either. Promoting the four cases by name into the gating tier is a test.yml require_green edit, which is not this package's file.

3. SubsystemDepsTest compares two READERS now, not two copies. Stated in slice 2 and in the .def, repeated here because it is the one thing D3 made weaker: a typo in a .def row can no longer be caught by that case. The compile-time half of D2's red-once (MGPipe.h:225/:230) is what carries the anti-drift weight, and the four static_asserts over the table are what stand behind the rows themselves.

4. The remaining half of D2's R-3, untouched. MGPSamplerView::Target still has no Count bound, no ...IsValid predicate and no Fatal{ProtocolCorruption} for an out-of-range value, unlike MGPipeResourceTarget. It is an MG_Pipe/MGPipeValueTypes.h change and nothing in this package needed it.

5. D2's own note is not rewritten. espryt-d2.md still says Managers.h:690-695 "is NOT corrected here" and that switching the readers "is two one-line changes for the integrator". Both were true of D2 and are the record of what D2 did; a package note is not a live statement of the tree. The tree's own live statements — SubsystemDeps.def's "WHO READS IT", Managers.h's resolver block and split-scenario-census.md's exclusion row — are the ones corrected, and split-scenario-census.md says so in a quoted block above D2's original text rather than by overwriting it.

6. The seven unguarded frontend-pointer-keyed containers under MG_Backend/DirectVulkan that D2's memo-purity sweep listed are still there. They are wave 2 A/B/C's territory and this package did not look at them.