- VulkanRenderer::LookupVaoDrawMemo's legacy arm read MG_Config::Features.PipeHandleAbaControl
a fourth time, bypassing the one-question accessor MagmaPipeAbaControlDefeatsIdentity that
55d2af9b's own message says all the control's sites go through. It could never be false
there: the control's early return fires ahead of BOTH arms, so a run that reaches the legacy
compare has the knob off. The compare is unconditional again, with a note saying where the
question is answered and to route through the accessor if that early return is ever narrowed
- which is the trap the dead line was, since narrowing it would have silently restored D18's
retired semantics at this one site.
- HandleRecycleScenario's vertex-array case still said "BOTH the VAO and the buffer are
recycled here so that a key built out of raw addresses matches", which the body has
contradicted since 55d2af9b: both buffers are created before the ABA window and neither is
deleted inside it, deliberately, so that no buffer traffic moves VkBufferManager's
slice-epoch counter inside the window. Only the VAO is recycled, and only its GL name - the
heap block is not handed back, which is why the knob rather than the allocator constructs the
AbaControl arms' collision.
- 55d2af9b claimed - in its message, in MagmaPipeArms.h, in VertexInputStateFactory.cpp and
in MG_IntegrationTest/CMakeLists.txt - that the AbaControlHandles lane defeats the
GENERATION in {slot, gen}. It does not, and no lane of that shape can. Magma's mint has no
death notification (nothing in MG_Backend/DirectVulkan consumes NotifyStateObjectDestroyed)
and returns a slot only through OnFrameBoundary's age sweep, kSweepInterval 256 /
kRetireAgeBoundaries 1024; HandleRecycleScenario issues five frame boundaries, so the
replacement VAO acquires against an empty free list and gets a BRAND-NEW slot at Gen 1
(measured: redVao slot=2 gen=1, greenVao slot=3 gen=1). The knob-off FRESH verdict there is
decided by the SLOT alone, and deleting ++m_entries[index].Gen leaves all 32 HandleRecycle
entries green - re-measured this round.
- What the lane does defeat is the object identity that SELECTS the slot, which IS the key the
handle arm ships, and that is what the three code sites now say. The two requirements are
mutually exclusive for the pixel-visible memo: a genuine slot reuse needs >= 1024 idle
boundaries after the dead object's last draw, which necessarily puts the two draws in
different frames, and ResolvedVertexBindings - the only memo carrying a GPU slice rather
than a layout - declines across frames by design.
- So the generation is covered where it IS expressible. MG_Test/Pipe/MagmaPipeIdentityTest.cpp
drives the mint's real retire -> reuse (1280 boundaries, with a keep-alive object holding the
first allocatable slot so the reuse is not the slot the control aliases onto) and asserts
four things: the retired slot comes back with Gen+1; with the knob OFF a memo stamped at
{slot, gen=N} is NOT served at {slot, gen=N+1}; with the knob ON it IS, out of one uncleared
and unclaimed entry; and a live object keeps its slot, its generation and its memo across two
sweeps, so the generation cannot be "fixed" by bumping it on every acquisition.
- The claim rule itself moves into MagmaPipeArms.h as MagmaPipeClaimSlotMemos so the suite
exercises production code rather than a copy of it. VertexInputStateFactory::MemosFor is now
one call to it and is otherwise unchanged, on both the knob-on and the knob-off path.
- Load-bearing, measured: with ++m_entries[index].Gen commented out, ctest -L unit in
build-push goes 1563/1566 - three of the four new cases red, one of them naming the inherited
0xDEAD payload out of the same slot - while ctest -R HandleRecycle stays 32/32. Restored, all
four pass in build-push and build-verify and skip visibly in the pull build, so the ctest name
sets stay identical (G2).
- MOBILEGL_PIPE_HANDLE_ABA_CONTROL asserted the corruption and saw correct pixels, so
DirectVulkan.HandleRecycle.AbaControl.*AVertexArray* was RED in an always-on
integration-gpu lane while every guard it was supposed to be defeating stood. Two
measured reasons, neither of them the {slot, gen} re-key: (1) D18 spelled the control as
"hash the raw BufferObject* instead of its lifetime id, and skip the vaoLifetimeId
compare", which only collides if the allocator hands the freed block back - it does not.
glGen* recycles the NAME, but a VertexArrayObject is 3920 bytes, past glibc's tcache, so
its chunk goes to the unsorted bin and is split by the next allocation the replacement
path makes; four create/delete cycles in one run gave four addresses ~1 MiB apart, and
the BufferObject behaves the same. (2) The reproducer put a frame boundary between the
arming draw and the recycled draw, and the only memo that carries a GPU slice rather
than a layout - ResolvedVertexBindings - declines across frames by design, so no key
collision whatsoever could have shown up in pixels.
- The control no longer asks the allocator for the collision: on both arms it replaces the
object identity in DirectVulkan's vertex-input keys with a constant, which is the
strongest form of "the block came back" and is deterministic. Three sites, all behind
one question (MagmaPipeAbaControlDefeatsIdentity): the buffer identity leaves
VertexInputStateFactory::ComputeHash, VertexInputStateFactory::MemosFor claims one entry
without its Owner compare, and VulkanRenderer::LookupVaoDrawMemo hands one entry back
uncleared ahead of both arms.
- That is what makes the control cover the key P2 SHIPS. Under MOBILEGL_PIPE_PUSH=0 the
handle arm is not executed at all, so the old control said nothing about the generation
in {slot, gen} - the whole of what makes the re-keyed memos ABA-safe. A second lane,
DirectVulkan.HandleRecycle.AbaControlHandles., runs the handle arm with the knob and
asserts the same corruption; D18's lane is kept verbatim beside it for the pre-handle arm.
- The reproducer's two draws now share a frame, and both buffers are realised before the
window, so a moved slice epoch cannot mask the ABA behind a gate that is not about
identity. Nothing else is relaxed: the frame serial, the slice epochs and the host-map
check stay in force, so a green arm still means "a replacement object was handed its
predecessor's resolved vertex bindings because the identity halves of the keys were
defeated".
- ExpectPixelsFor now prints what it OBSERVED (STALE/FRESH/NEITHER) next to what the arm
expected, on every arm and whether or not the case passes.
- Knob-off is unchanged and the pull build is untouched: every new branch is
#if MOBILEGL_PIPE_PUSH, and symbol_report.py --threshold 0 against the pre-P2 baseline
still reports 0 added / 0 removed / 0 renamed and the same four resized symbols
(RenderState::RenderState, SetCapability, IsCapabilityEnabled, _GLOBAL__sub_I_DirectGLES.cpp).
- The live-object high-water mark is the number review v2's MAJOR 1 wants measured on
minecraft-1.21.4-in-world and ...-sodium-in-world, and no desktop gate can produce it. It
was emitted at MGLOG_D, which is compiled out of every build that ships and of every build
P2 measures, so the line existed only in a configuration nobody runs.
- MGLOG_I instead, still only on the allocate-a-new-slot branch and still only at powers of
two from 1024 up: at most a handful of lines for a whole session, never one on a draw
(ROADMAP.md:7). Declared as a narrow deviation from D20's "MGLOG_D for anything
non-critical" in the comment beside it.
- MagmaPipeIdentityTable was a FIXED 2048/8192-entry, 2-way set-associative LRU. Above
capacity it evicted LIVE objects, and every memo keyed on the handle died with them: a
verbatim transcription of the previous Acquire lost 54% of uses' handles at 2500 live VAOs
against 2048 entries, and 20% at 1024 live VAOs once the lifetime ids are sparse (an app
that creates and destroys VAOs - the Minecraft chunk shape this exists for).
- Two of the three memos it fed had NO capacity before this package: the content-hash memo
and the resolved-state memo were unbounded mutable fields on VertexArrayObject. Eviction
there turns one ComputeHash per VAO reconfiguration into one per DRAW; once the buffer
table thrashes too, the vertex-input content hash becomes a per-draw value that inserts a
fresh heap-allocated BackendVertexInputState into an unbounded map on every draw, swept
only every 256 frame boundaries. That is a worse leak than the one the fixed table was
introduced to avoid.
- So the mint grows on demand and reclaims by AGE: a lifetime-id map with a one-entry front
memo, a free list, and an OnFrameBoundary sweep on the same cadence and retirement age as
the cache entries those slots key. Footprint tracks the live DRAWN working set instead of
objects ever created, which is the property MG_Impl/Pipe/SlotAllocator cannot have here
(nothing in P2 can call its Free). Re-run of the same workloads: handle churn is 0.0% at
512, 1024, 2048, 2500, 3000, 4000, 8192, 10000 and 16000 live objects, consecutive and
sparse ids alike, at 1 and 5 acquisitions per use.
- VertexInputStateFactory::m_vaoMemos follows the mint with no capacity of its own, through a
chunked table whose entry addresses never move - which is what the fixed table's only real
guarantee was, and D12.4's grow-on-demand ask without a relocating Vector.
- VulkanRenderer::m_vaoDrawMemoTable deliberately keeps the base ref's 2048 entries and the
base ref's older-frameSerial victim rule, and changes only its KEY. It is the one memo of
the three that had a capacity before P2, a VaoDrawMemo is ~450 B, and losing one costs one
vertex-binding re-resolve. Measured steady-state miss rate against the base ref's
address-hashed table: 0.0% vs 6.4% at 512 live VAOs, 0.0% vs 24.0% at 1024, 0.0% vs 60.0%
at 2048, 36.2% vs 69.5% at 2500, 63.5% vs 79.1% at 3000; both are ~100% at 4096 (2x
capacity), where an LRU on a cyclic pattern cannot win.
- The two tables are now a MagmaPipeIdentityTables member of VulkanRenderer, handed to its
VertexInputStateFactory, instead of two function-local statics that outlived every context
and shared one reclamation clock across two.
- A Gen that reaches 2^32-1 retires its slot for good rather than wrapping. MOBILEGL_ASSERT
is compiled out of every build P2 runs, and a DEBUG-level build of this tree does not
compile at all (MG_Util/Types.h uses MOBILEGL_ASSERT before MGLOG_F is declared - untouched
since the base ref, and not this package's file), so the defence has to be on the release
path to exist.
- The no-CSO pipeline-memo fallback stops using MGLOG_W_ONCE. MOBILEGL_LOG_ONCE_INTERNAL is
an unconditional std::atomic_flag::test_and_set - a locked xchg per evaluation, not "one
static bool test" - and this site is on the per-draw path in exactly the configuration that
reaches it. A plain per-renderer bool replaces it, and the comment now says what the
warning's absence does and does not prove (nothing at all while bit 0 is clear).
- MOBILEGL_PIPE_LEGACY_MEMOS=0 with kMGPipeSubsystemRenderState clear still runs the
pre-handle state hash - there is a correct answer there and bit 0 is not Track H, so it is
not fatal - but it is no longer silent: the startup gate names the combination.
- The D12.3 static_assert block now names D19's DynamicChunksCoverMagmasDynamicTailKey, whose
ctest entry lives in package A's file, so the integrator can see which half is missing.
- MagmaPipeSlotIndex subtracted kMGPipeFirstAllocatableSlot unconditionally, so a null
handle would have indexed a per-slot table at ~0u. It is unreachable - both lifetime-id
sources start at 1, which is why Acquire's zero guard never fires - but the consequence
of being wrong about that is an out-of-range write, not a wrong answer, so it is asserted
and the index is pinned to 0 in a release build.
- MOBILEGL_PIPE_LEGACY_MEMOS=0 no longer aborts a draw. D14 spends that lever at
STARTUP and only on a Track-H subsystem, so MagmaPipeValidateSubsystemConfiguration
runs once from VulkanRenderer::Initialize and checks bit 6 alone: an Espryt-side
bitmask cannot kill a Magma run, and bit 0 - which is not Track H and not a memo
re-key - is out of the lever's scope entirely. MOBILEGL_PIPE_PUSH=0x60 with the
lever off went from 9/9 aborted to 432/432.
- A pipeline memo with no render-state CSO bound falls back instead of aborting.
delete_render_state clears the binding, so the null handle is reachable on any
tree; the fallback is the pre-handle state hash where one is compiled, and the
client's own MGPipeComputePipelineSubsetHash over the same 396 pipeline bytes
where it is not - which is what makes -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF a runnable
configuration (180/432 aborted before, 432/432 now) instead of a build that dies
on its first draw. The fallback warns once, so a run that never keys on a CSO
handle says so in its log instead of passing silently.
- The handles are minted by a fixed-capacity, self-recycling identity table in the
backend, not by MG_Impl's client allocator. Nothing in P2 frees a VertexElementsCso
or Buffer slot - the frontend has no death notification Magma can hook - so the
allocator's live Allocate and dead Free grew one SlotState plus one hash-map node
per object ever created, for the life of the process. The table is 2-way
set-associative with an LRU victim and a Gen bump on reuse: bounded (32 KB for
VAOs, 128 KB for buffers), exactly as ABA-proof, and it takes MG_Backend's only
include of MG_Impl back out.
- Both per-slot memo tables are now a BIJECTION with that mint rather than a masked
direct map, so two live VAOs cannot share an entry and the eviction decision lives
once, in the identity table, instead of once per consumer table. The density claim
the masked tables rested on was false while slots grew monotonically, and the
masked form had also dropped the second candidate and the frame-serial victim
choice the address-hashed table used to have.
- snap.vaoHandle is stamped only when bit 6 is on. It was guarded by the compile
switch alone, so MOBILEGL_PIPE_PUSH=0 - the all-pull control D14 defines as
reproducing P1 exactly, and the arm D.4.3's T2 is measured on - paid a mint per
new VAO and a compare per draw for a field that arm never reads.
- Every re-keyed Track-H site now asks the same MagmaPipeTrackHArmIsHandles helper,
including VertexInputStateFactory::ComputeHash, which decided for itself before
and could key on the pre-handle identity while its neighbours keyed on the handle.
- The pull build's two pipeline-memo sites keep the base ref's text statement for
statement: G1 is back to the contract's four resized symbols, 0 added/removed.
- P2 D12.5 (ARCHITECTURE.md 9.5). VertexArrayObject carried three `mutable` memos for the
backend: a content hash, a raw pointer into VertexInputStateFactory's heap-allocated
cache entry plus that cache's eviction epoch, and two aux words. A frontend state object
holding the backend's pointer is what P2 retires - under split the backend is in another
process and its cache entry has no address a client could store.
- The hash and state memos move into a slot-indexed table the FACTORY owns, keyed on the
VAO's {slot, gen} and guarded by exactly the same config version, so nothing is
recomputed more often than it was. Fixed and direct-mapped for the same reason m3's
VaoDrawMemo table is: nothing frees a VertexElementsCso slot in P2, so a grow-on-demand
table would keep one entry per VAO ever created. 2048 x 48 B is 96 KB.
- The AUX memo is deleted rather than moved, as the brief says: its two words already live
in VulkanRenderer::VaoDrawMemo (layoutHash / layoutAuxMasks) and GetBackendAuxMemo has no
live reader anywhere in the tree - the only writer was the line this commit stops
executing.
- The eviction-epoch dance shrinks with them. The PROCESS-WIDE s_evictionEpochSource exists
because the memos live on frontend VAOs and therefore outlive the factory; the handle
arm's table dies with the factory, so a per-instance counter is enough there. The epoch
itself stays - it guards the POINTEE, which is still a cache entry a frame boundary can
erase, and moving the memo does not change that. (The brief reads as if a slot-indexed
table removes the need for an epoch; it removes the need for a process-wide one.)
- The three draw-path readers that asked the VAO "is your content hash already memoized?"
now ask whichever side owns the memo, through a force-inlined wrapper so the PULL build's
two loads stay two loads.
- All three accessors and their storage are kept under MOBILEGL_PIPE_LEGACY_MEMOS rather
than deleted from the file, because that is the arm the pre-handle A/B runs (D14) and
because a pull build forces the option ON, where G1 admits no change at all. Configuring
with -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF is what makes the deletion real, and that build
compiles clean - which is the check that nothing else still reaches for them.
- Verification: pull symbol_report --threshold 0 is 0 added / 0 removed / 0 renamed with
the contract's four resizes and no fifth; ctest -L unit 1489/1489 in both the pull and
the push build; ctest -L integration-gpu -R DirectVulkan 432/432 under the default
bitmask and 432/432 under MOBILEGL_PIPE_PUSH=0. The LEGACY_MEMOS=OFF build compiles but
cannot RUN on this tree, and that is the D14 gate working rather than a defect: no
tracker binds a render-state CSO here, so the handle arm has no key and
Fatal{PipeLegacyMemosDisabled} fires at the first draw instead of the memo quietly
aliasing every render state onto one entry. Re-run it once p2/tracker has landed.
- Track H subsystem 4 (P2 brief D12.4, ARCHITECTURE.md 9.5), behind
kMGPipeSubsystemMagmaVertexInput.
- VertexInputStateFactory::ComputeHash's buffer identity component becomes the buffer's
{slot, gen} - "lifetimeId -> gen mixed into every server-side content hash". Both are
equally ABA-proof (the allocator maps one onto the other and bumps Gen only on slot
REUSE); what changes is that the hash now carries the identity the SERVER will be handed
once buffers travel as handles, instead of a number only the client can mint.
- LookupVaoDrawMemo becomes a direct slot index: the slot IS the index, and the whole
validation is one handle compare. Gone with the re-key are the Fibonacci mix of the VAO's
address, the two-way probe, the frame-serial eviction choice and the (pointer, lifetime
id) pair - slots are dense by construction, so consecutive VAOs land in consecutive
entries and the collision the address hash existed to spread does not arise below the
table size.
- The table stays FIXED at 2048 entries and the slot index wraps, where the brief calls
for a grow-on-demand vector. Reason, and it is a tree fact the brief does not carry:
nothing in P2 frees a VertexElementsCso slot. The frontend death notification is Espryt
0b's e2 and it covers Espryt's six kinds; buffers are the only kind with an OnDestroy
hook today. A grow-on-demand table would therefore hold one ~1 KB VaoDrawMemo per VAO
EVER created, which on a chunk-cycling Minecraft frame is tens of megabytes. Above the
table size this degrades to a direct-mapped cache validated by the full {slot, gen}:
never wrong, only colder, and strictly better than the address hash it replaces.
Revisit when object deletion reaches the client allocator.
- SetupDrawSnapshot's VAO identity collapses to the same handle - one compare instead of
(address, lifetime id) - so the snapshot and the draw memo cannot disagree about whether
the VAO moved. The config version stays: it answers a different question.
- Handle acquisition sits behind a one-entry memo in the renderer. Acquiring is a hash
probe into the allocator's lifetimeId -> slot map and LookupVaoDrawMemo runs per draw, so
without it the arm would have swapped the address hash it deletes for another probe; a
run of draws over one VAO now pays a single Uint64 compare. Magma acquires the handles
itself because the tracker does not emit object-class state in P2 (it emits for dirty
bits 0-4 only); when it does, these become reads of what the client already sent.
- Negative control C (MOBILEGL_PIPE_HANDLE_ABA_CONTROL, brief D18) is implemented here
because the two guards it defeats live here: it makes ComputeHash hash the raw
BufferObject* and makes LookupVaoDrawMemo skip the lifetime-id compare - the exact state
the table was in before the ABA fix. It applies to the PRE-HANDLE arm, which is what
HandleRecycleScenario.AbaControl runs (MOBILEGL_PIPE_PUSH=0), and it is what proves that
scenario's reproducer still reproduces instead of passing for the wrong reason.
- Verification on this tree: ctest -L integration-gpu -R DirectVulkan is 432/432 under the
default bitmask and 432/432 under MOBILEGL_PIPE_PUSH=0, and -L unit is green. Pull build
symbol_report --threshold 0: 0 added / 0 removed / 0 renamed, 4 resized, all four the
contract commit's.
- P2 D12.3. ApplyDynamicDrawStateTail keeps reading GetRenderStateParametersVersion, and
under MOBILEGL_PIPE_PUSH that accessor is RE-SOURCED: it returns
PipeInputs::m_renderStateParametersVersion, which the applier publishes from
MGPDynamicState::Version and MGPBindRenderState::Version. The gate now reads what the
client pushed rather than what the backend pulled.
- The brief expects the same change to stop a PIPELINE-only change invalidating the tail.
It does not, and the tree is right against the brief: the applier publishes
bind_render_state's Version into the same counter, and it has to - Espryt's
SyncRenderState uses that counter as its all-state change detector and G5 forbids
touching one line of it, so a bind that rewrote the pipeline half while leaving the
counter still would make Espryt skip re-syncing the state it just changed. Getting the
finer gate needs a second, dynamic-only version on the wire, which is a CONTRACT change;
recorded for the integrator rather than smuggled in here. The second-level DynamicTailKey
compare already absorbs a pipeline-only change at the cost of one key build and no
vkCmd*, exactly as it did before P2.
- The coverage check D12.3 asks for, as static_asserts rather than a unit test: every
RenderStateParameters member DynamicTailKey reads is checked against the P2 chunk table
(MGPipeRenderStateSpans.h), including the three stencil members PER FACE, since D6
splits StencilFaceState through the middle. The tail's hand-written input inventory and
the offsetof-derived chunk table were written for different reasons, so making them
check each other is free evidence, and a chunk edit that demoted one of these is a build
break here instead of a tail that stops being re-run when its input moves. A ctest entry
would have had to live in MG_Test/Pipe/RenderStateSpansTest.cpp, which the ownership
table gives to package A; a static_assert in the file that owns the reader is both
in-scope and stricter.
- ScissorTestEnabledMask is the one input that is NOT dynamic, and the brief says it
should be. The tree wins: the split's only rule is "pipeline iff a setter that calls
BumpVersions writes it", and SetCapability(ScissorTest) does, so it sits in pipeline
chunk P6 with the other capability bools. It is pinned with the assertion INVERTED, so
demoting it - which would be a real G7 violation - is also a build break. Reading it in
the tail stays harmless because BumpVersions moves both counters together.
- Push-only: the whole block is inside MOBILEGL_PIPE_PUSH and the pull build is unchanged
(symbol_report --threshold 0: 0 added / 0 removed / 0 renamed, 4 resized, all four the
contract commit's).
- P2 D12.1. GetOrCreatePipeline's memo compared a VALUE hash of the pipeline-relevant
fixed-function state that Magma recomputed itself. After P2 the CLIENT hashes exactly
those bytes when it mints a content-addressed render-state CSO
(MGPipeComputePipelineSubsetHash over the seven pipeline chunks), so the bound CSO
handle IS that key and ComputePipelineStateHash was doing the boundary's work twice.
The client's pipeline subset is a strict SUPERSET of the 24 members the hash read, so
the handle discriminates at least as finely as the hash it replaces.
- renderPassHash STAYS in the key, and that is load-bearing rather than conservative:
ComputePipelineStateHash was never a pure function of RenderStateParameters - its
signature took colorAttachmentCount and rasterizationSamples, and
ResolveEffectiveSampleMask reads the latter - so those two render-pass facts have to
stay separated by something. entry.renderPassHash already separates them (the pass
hash folds each attachment's sample count and the attachment set), which is why
collapsing the state half onto a handle loses no discrimination.
ResolveEffectiveSampleMask is NOT deleted with the hash: it is a payload computation,
and it keeps reading Multisample / SampleMask / SampleMaskValue out of the working
block.
- Both memo probes are re-keyed, not just the full path's: TrySetupDrawFastPath carries
its own copy of the probe, and a fast path that keyed differently from the full path
would hand back a pipeline the full path would not have matched.
- The arm is chosen at runtime, per D14: kMGPipeSubsystemRenderState in the
MOBILEGL_PIPE_PUSH bitmask AND a non-null bound CSO. The second half is not belt and
braces - a tree whose tracker does not emit create/bind_render_state yet has no handle
to key on, and keying every draw on the null handle would alias every render state onto
one memo entry. Falling into the pre-handle arm with Features.PipeLegacyMemos=0 is
Fatal{PipeLegacyMemosDisabled}, so HandleRecycleScenario.Handles cannot go green by
quietly running the old code.
- ComputePipelineStateHash and its five cached-hash members (m_pipelineStateHash{,Valid,
Version,ColorCount,SampleCount}) survive only under MOBILEGL_PIPE_LEGACY_MEMOS, which
a pull build forces ON: they exist purely to avoid re-hashing, and the handle arm never
hashes. InvalidatePipelineMemo loses them on the same condition.
- New MagmaPipeArms.h holds the two-switch arm selector shared by the P2 Magma re-keys.
- G1, pull build, symbol_report --threshold 0 against ~/w7/p2-before-libMobileGL.so:
0 added, 0 removed, 0 renamed, 4 resized - and all four are the CONTRACT commit's
(RenderState::{RenderState,SetCapability,IsCapabilityEnabled} and
_GLOBAL__sub_I_DirectGLES.cpp). This commit adds none: every edit is inside a
MOBILEGL_PIPE_PUSH arm and the pre-handle statements are left where they stood, which
is why ResolveBoundRenderStateCso is push-only rather than a shared helper - an earlier
shared-helper shape moved 104 bytes of GetOrCreatePipeline around for no behaviour
change and the gate saw it.
- BackendSlotTable links every instance into a per-type holder list; OnFrontendObjectDestroyed resolves the handle once, drops the twin in each holder by handle and frees the slot once, last - a notice delivered to one registry left the fixture's saved copy holding a dead twin, and its driver storage, for the life of the process (review v4 minor 2)
- ReclaimDeadSlots and CollectGarbageNow leave the slot table: nothing called them, so the header's "backstop" was a claim without a caller; the registry's pre-P2 CollectGarbageNow is a no-op on the handle arm (minor 1)
- HandleOf no longer memoises a null answer, which a second holder's acquire could never refresh (minor 8)
- EnsureProcessTeardownSentinel is armed by the slot table's first insertion, as D13 says; the registry arms it only on the legacy arm (minor 9)
- new SanityTest cases: OneDeathNoticeDropsTheTwinInEveryHolderOfTheKind, ASavedCopyOfARealRegistryDropsTheTwinOnTheSameNotice, ANegativeLookupIsNotCachedAcrossAnotherHoldersAcquire, and EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping, which runs InitPbufferSurface under the pair in a forked child and fails naming both knobs if InitDisplayAndContext ever stops there again (minor 4)
- AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane writes a per-process, per-case log path and restores MOBILEGL_LOG_FILE_PATH and MG_Config::Features through RAII guards on every exit path (minor 5)
- ForEachLive walked with a range-for and handed fn a reference INTO m_slots, so a callee
that reached GetOrCreate on the same table would resize the vector under both. Index loop
and a copied twin, the shape ReclaimDeadSlots already uses. The one caller today happens
not to insert; that is not a property the walk should depend on.
- GetOrCreate(nullptr) reset the parking twin on EVERY call, so a second null call destroyed
what the first was handed. The map arm kept its null-keyed entry until a sweep, so this
was an arm difference in the one path (SyncTextureObjectToBackend) that documents relying
on the tolerance. It now keeps the parked twin, and the case makes a second call.
- The one-entry memo's comment claimed the three per-draw resolution paths ask for the same
object every draw. Two of them do not: BindCurrentFBO resolves both targets in a frame and
ResolveUnitSamplerBackend asks per texture unit, so both thrash a single-entry memo and
pay a probe P1 did not. The comment now says so and names the fix (per-unit / per-target)
and the gate that would price it (G11, device-side, owed).
- HandleOf caches a NULL answer too - deliberate, because a bound-but-never-synced object
would otherwise re-probe every draw - and what makes it safe is that GetOrCreate refreshes
the memo. Nothing pinned that; RepeatedLookupsOfALiveObjectKeepOneHandle now does.
- Removed the dead #if MOBILEGL_PIPE_PUSH nested inside #if MOBILEGL_PIPE_PUSH in
ScopedDetachedTextureFramebufferAttachments.
- e2 was landed for two of six kinds, so Texture, Framebuffer, SamplerCso and
VertexElementsCso still discovered death in a sweep and ROADMAP.md:18's "delete the GC"
was undelivered. TextureObjectBase (the one base every concrete texture derives from),
FramebufferObject, SamplerObject and VertexArrayObject now raise
NotifyStateObjectDestroyed from their destructor, on RenderbufferObject's pattern: out of
line, declared only under MOBILEGL_PIPE_PUSH, so the pull build keeps its implicit
destructor and its symbol set (G1 still 0 added / 0 removed / 0 renamed and 0 resized
against p2/contract).
- With all six announcing, BackendSlotTable loses BOTH sweep drivers: no draw tick, no
creation tick, no kGCInterval / kCreationGCInterval / m_gcTick / m_creationTick.
CollectGarbageIfNeeded() is empty on this arm; CollectGarbageNow() stays as an EXPLICIT
collection and is the backstop for a notice that InProcessTeardown() drops.
- The seven CollectGarbageIfNeeded call sites in DirectGLES.cpp keep their spelling because
they are the legacy registry's driver and that arm is still compiled beside this one; the
registry's body is now guarded on MOBILEGL_PIPE_LEGACY_MEMOS, so a build without the
legacy arm has no collector at all. On the handle arm each site is a predicted branch.
- The weak_ptr per entry stays for exactly two jobs it is honest about: ForEachLive()'s
strong hand-over to ScopedDetachedTextureFramebufferAttachments, and the explicit
collection. It is never an identity test; Gen is.
- Tests: AProgramAndARenderbufferAnnounceTheirOwnDeath becomes
EveryReKeyedObjectClassAnnouncesItsOwnDeath and drives all six classes, by membership
rather than count because every texture owns a private sampler that also announces;
ObjectChurnAloneDrivesTheSweep becomes
AnnouncedDeathKeepsObjectChurnFromAccumulatingWithoutASweep and pins that 256 churned
objects hold one live twin at a time with no CollectGarbage* call anywhere.
- Fatal{PipeLegacyMemosDisabled} was raised from InitDisplayAndContext(), i.e. from inside
eglMakeCurrent. The integration harness pre-flights that exact sequence in a forked child
(MG_IntegrationTest/Harness/HeadlessGL.cpp) and reports a child that dies on a signal as
"no usable GPU/display/ICD", so every scenario SKIPPED and ctest called the lane 100%
passed while running nothing - on the very pair of env vars the D14/D18 A/B is driven
with. ROADMAP.md:7 forbids a gate that cannot go red for the reason it exists.
- The arm decision becomes a pure function of the two knobs, ClassifyEsprytSlotArm(), with
three verdicts. Bring-up now calls DiagnoseEsprytSlotArm(), which names both knobs at
ERROR and RETURNS; the stop stays in ResolveEsprytSlotTablesArm(), which the inline latch
reaches at the first twin lookup - a scenario body, where a crash is a test failure.
- A process that never looks a twin up never needs an arm and is no longer stopped by one
it would not have used. That is the only behaviour this moves.
- SanityTest gains an always-on case: the four knob combinations of the pure classifier,
that the diagnosis does not stop, and that the stop is SIGABRT whose log line names
PipeLegacyMemosDisabled, MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS=0 and the bit -
the message and not merely the signal, because "Subprocess aborted" alone tells an
operator nothing. It skips visibly in the pull and no-legacy builds (G2 name parity).
- UnitBindingsSnapshot was split by #if MOBILEGL_PIPE_PUSH, so a push build ran P2's
lifetime-id debounce on the MOBILEGL_PIPE_PUSH=0 arm too. That arm has to reproduce P1
(ConfigLoader.cpp), or the integrator's A/B measures this slice's mechanism on both sides
and attributes it to neither - the same complaint g_fbSlotCache was already fixed for. The
snapshot now carries P1's WeakPtr fields beside the lifetime ids whenever the legacy arm
is compiled, and Capture/Unchanged pick by EsprytSlotTablesEnabled(). The two answers are
equivalent (OwnerEquals on two empty pointers is true and LifetimeIdOf(nullptr) == 0 == 0;
a live-versus-expired control block and two distinct lifetime ids both compare unequal),
so this is A/B fidelity, not a behaviour change, and a build with no legacy arm carries
neither the fields nor the branch.
- TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin deliberately never swept, so it left
a live MGPipeKind::Query slot behind for good, and ObjectChurnAloneDrivesTheSweep reads
HighWater/LiveCount of that same process-global kind. Deltas made them pass today, but
--gtest_shuffle or a third case on kind Query would have made them interact. The
two-holder case now has a kind to itself and returns its slot at the end.
- Its comment claimed two live tables of one kind "cannot arise outside this case". They
can and do: ScopedDirectGLESTextureBindings holds a second live table of kind Texture, and
package D's subsystem 4 re-keys VaoDrawMemo out of the same per-kind allocator. The
comment now states the real hazard (whichever holder frees first orphans the other's
entry; safe, because Free is generation-guarded and FindByHandle compares Gen, but not
free) and flags it for the integrator.
- P2 step e2, as far as the file-ownership table lets one package take it. New frontend
header MG_State/GLState/StateObjectDeathNotice.h carries BufferBackendOps' shape for the
other six kinds: an ops table the backend fills in, and one entry point that takes
{kind, lifetimeId} rather than the object, because by the time the last SharedPtr has
dropped there is no object left to pass and the lifetime id is exactly what the client
slot allocator resolves a handle from. Declared only under MOBILEGL_PIPE_PUSH, so the
pull build's symbol set is untouched.
- BackendSlotTable::DestroyByLifetimeId drops the twin and returns the slot at the moment
the object goes, instead of at the next sweep - which for a renderbuffer or a texture
atlas is the difference between freeing the driver allocation now and freeing it 64
creations from now. It returns the slot only when THIS table holds it: two holders of one
kind already exist (the ScopedDirectGLESTextureBindings fixture; Magma's subsystem-4
table shares the VertexElementsCso kind), and a table that never twinned the object must
not free a slot the other one still names. The legacy arm keys on the frontend heap
address, cannot answer a notice at all, and keeps the sweep - which is the announced-
versus-discovered half of the A/B the compile-time arm exists for.
- Managers.cpp registers one dispatcher for all six kinds from ResolveEsprytSlotTablesArm(),
i.e. exactly when the arm that can answer a notice is the arm that runs, and drops a
notice that arrives after exit() has begun.
- FIRING it needs a destructor per class, and the P2 ownership table gives
{Texture,Framebuffer,Sampler,VertexArray}State/* to other packages, so only ProgramObject
and RenderbufferObject raise it here. The other four still rely on the sweep; their four
one-line calls retire it entirely.
- Three cases pin the three halves: the slot comes back with no sweep and the notice is
idempotent and does not free another holder's slot; a program and a renderbuffer announce
their own death when the last SharedPtr drops and not before; and the handle arm actually
installs a consumer, rather than the two halves each being fine on their own.
- The handle arm took only ONE of the registry's two sweep drivers. The map arm sweeps
every 64 first-time insertions BECAUSE object churn, not draw count, is what makes the
sweep urgent: a CTS-shaped case runs ~10 per-draw ticks, so the 1024-tick draw-path
driver alone spans ~100 cases' worth of dead, gigabyte-sized twins. BackendSlotTable now
carries the same kCreationGCInterval = 64 creation tick, swept before the entry reference
exists for the same reason the map arm sweeps there. Without this the slice REGRESSED the
memory it was supposed to leave unchanged.
- Fatal{PipeLegacyMemosDisabled} now aborts. It logged and then returned false, which fell
straight into the legacy arm the operator had just made unreachable: a green run measured
on the wrong arm, and the exact lever HandleRecycleScenario's arms are selected with. It
is also resolved at backend context creation now, not on the first twin lookup, so a
process that twins nothing still learns its knobs leave it with no arm at all.
- EsprytSlotTablesEnabled() becomes an inline latch over an out-of-line resolver. It is
consulted on every Find/GetOrCreate/HandleOf/ForEachLive/CollectGarbage*, i.e. several
times per draw, and as a cross-TU call with no LTO that was a PLT call per lookup.
- HandleOf keeps a one-entry lifetimeId -> handle memo, so the three per-draw resolution
paths whose TwinLookupMemos this slice deleted go back to an integer compare plus an
array index instead of the allocator's ByLifetimeId hash - which is the "direct slot
indexing" the memo removal was traded for. It cannot serve a stale answer: a lifetime id
is never handed out twice, and FindByHandle compares Gen anyway.
- GetOrCreate(nullptr) returns a parking slot instead of dereferencing null in a release
build; the map arm inserted a null key and SyncTextureObjectToBackend documents relying
on that tolerance.
- ReclaimDeadSlots moves the twin out before it writes the entry, so a twin destructor that
re-entered GetOrCreate and grew m_slots could not make the writes land in freed memory.
- The framebuffer binding-slot cache is gated on kMGPipeSubsystemEsprytSlots rather than on
the compile-time MOBILEGL_PIPE_PUSH, so MOBILEGL_PIPE_PUSH=0 stays the faithful
all-subsystems-pull control ConfigLoader.cpp documents. The poison bypass stays closed on
the arm that ships.
- MGB_TWIN_KIND_PARAM/ARG stop leaking into every TU that includes Managers.h: the twelve
declaration and definition sites name a TwinRegistry alias template that swallows the
kind in the pull build, and the one remaining macro is #undef'd after the class.
- The twin lookup inside BindCurrentFBO stops shadowing the framebuffer binding slot in a
function whose whole subject is which "slot" is meant.