From a690032f859829bac4dd44da72ede071d507b926 Mon Sep 17 00:00:00 2001 From: rereview Date: Tue, 8 Sep 2026 23:59:48 -0400 Subject: [PATCH] [Fix, Test] (MG_Pipe, clientfb, clientsp): retire every emitter's entry at the object's death - the six death helpers freed the slot and told no emitter, so a dead-but-unrecycled texture handle still resolved to the freed ITextureObject* (the allocator's generation moves only at the next hand-out) and the drain list kept the level: `glTexImage2D; glDeleteTextures; ` called a virtual on freed memory from the next validate point (final review C-2); the helpers now forward to the texture, renderbuffer, framebuffer, sampler-view and shader-CSO emitters between the wire delete and the free, ResolveTexture refuses a dead slot loudly on IsLive, the sticky-mask producers stamp the generation they write under, and the delete-then-use sequence is pinned for every kind, for a recycled slot, and under MALLOC_PERTURB_ on both backends --- MobileGL/MG_Impl/Pipe/FramebufferEmit.h | 20 +++ MobileGL/MG_Impl/Pipe/PipeFill.cpp | 29 ++++ MobileGL/MG_Impl/Pipe/ProgramEmit.h | 14 +- MobileGL/MG_Impl/Pipe/SamplerEmit.h | 10 +- MobileGL/MG_Impl/Pipe/TextureEmit.h | 102 ++++++++++-- MobileGL/MG_IntegrationTest/CMakeLists.txt | 30 ++++ .../Scenarios/P4aFinalFixScenario.cpp | 150 ++++++++++++++++++ MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp | 24 ++- MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp | 23 ++- MobileGL/MG_Test/Pipe/TextureEmitTest.cpp | 110 ++++++++++++- 10 files changed, 484 insertions(+), 28 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h index 90f4b5e2..90bc294d 100644 --- a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h +++ b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h @@ -389,6 +389,26 @@ namespace MobileGL::MG_Pipe { // ---- what a unit case reads. The emitter builds INTO these and hands the applier the // same objects, so "what was emitted" costs no copy. ---- + // ---- the death half (P4a final review C-2) ---- + // + // Called by the contract's death helper before the slot is freed (there is no wire + // delete for this kind, D-I2, so this is the only client-side thing a framebuffer's + // death has to do). The per-object Named latch is the entry: a recycled handle's Gen + // already refuses the stale latch, so this is hygiene rather than a fix - the rule + // (ID-8) is that whatever mints a handle retires everything it keeps under it at the + // death, and every P4a kind takes the same shape. Gen-keyed for a late notice. + void NoteFramebufferDied(MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_named.size()) return; + if (m_named[slot].Gen == handle.Gen) m_named[slot] = NamedEntry{}; + } + // "Does this emitter hold a Named-record latch for this handle at its generation." + Bool NamedRecordIsLatched(MGPipeHandle handle) const { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_named.size()) return false; + return m_named[slot].Has && m_named[slot].Gen == handle.Gen; + } + const MGPFramebufferState& LastDraw() const { return m_lastDraw; } const MGPFramebufferState& LastRead() const { return m_lastRead; } const MGPFramebufferState& LastNamed() const { return m_lastNamed; } diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 12262ae2..caa9b01d 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -1370,11 +1370,24 @@ namespace MobileGL::MG_Pipe { } } // namespace + // THE EMITTER IS TOLD BETWEEN THE WIRE DELETE AND THE FREE (P4a final review C-2), for + // every kind that keeps client state under a handle: a texture's drain entries, pointer, + // cache reference and latches; a renderbuffer's entry; a framebuffer's Named latch; a + // sampler view's and a shader CSO's record memo. Before this the six helpers freed the slot + // and told nobody, so the texture emitter kept the freed ITextureObject* and the level on + // the drain list, and `glTexImage2D; glDeleteTextures; ` called a virtual on freed + // memory from the next validate point. The forward is the P3a shape + // (MGPipeEmitVertexElementsDestroyAndFree's emitter.NoteRecordDestroyed) applied to the + // five P4a kinds that have an entry to retire; the content-addressed sampler CSO keeps + // none per object (its death is the cache's LRU, ID-17). Unconditional in a push build, + // like the mints: the entries exist whether or not the family bit is set. Bool MGPipeEmitSamplerViewCsoDestroyAndFree(Uint64 lifetimeId) { const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerViewCso, lifetimeId); const Bool published = EmitDeleteIfPublished(MGPipeKind::SamplerViewCso, handle, &MGPipeApplyDeleteSamplerView); + ForwardWhenWired( + MGPipeSamplerEmitterInstance(), [&](auto& emitter) { emitter.NoteRecordDestroyed(handle); }); // THE NOTICE IS RAISED FOR THIS KIND TOO, and the reason it once was not is wrong: // NotifyStateObjectDestroyed takes a KIND and a lifetime id, not an object // (StateObjectDeathNotice.h - one entry point for every kind rather than one ops table @@ -1394,6 +1407,11 @@ namespace MobileGL::MG_Pipe { const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, lifetimeId); const Bool published = EmitDeleteIfPublished(MGPipeKind::Texture, handle, &MGPipeApplyResourceDestroy); + // The emitter retires its entry while the handle still resolves (C-2): the drain list + // drops the dead texture's levels, the raw pointer goes, the built-in sampler's cache + // reference is given back, the latches and the sticky mask are cleared. + ForwardWhenWired( + MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.NoteTextureDied(handle); }); NotifyAndFree(MGPipeKind::Texture, lifetimeId, handle); // THE SAMPLER VIEW DIES WITH ITS TEXTURE, because it is minted off the same lifetime // id: one SamplerViewCso per ITextureObject (D-F2), re-issued on the same handle @@ -1430,6 +1448,8 @@ namespace MobileGL::MG_Pipe { MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, lifetimeId); const Bool published = EmitDeleteIfPublished(MGPipeKind::Renderbuffer, handle, &MGPipeApplyResourceDestroy); + ForwardWhenWired( + MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.NoteRenderbufferDied(handle); }); NotifyAndFree(MGPipeKind::Renderbuffer, lifetimeId, handle); return published; } @@ -1454,6 +1474,8 @@ namespace MobileGL::MG_Pipe { // whatever it owed", which for a framebuffer is the death notice this just raised. const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::Framebuffer, lifetimeId); + ForwardWhenWired( + MGPipeFramebufferEmitterInstance(), [&](auto& emitter) { emitter.NoteFramebufferDied(handle); }); NotifyAndFree(MGPipeKind::Framebuffer, lifetimeId, handle); return false; } @@ -1463,6 +1485,11 @@ namespace MobileGL::MG_Pipe { MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerCso, lifetimeId); const Bool published = EmitDeleteIfPublished(MGPipeKind::SamplerCso, handle, &MGPipeApplyDeleteSamplerState); + // NOTHING TO RETIRE IN AN EMITTER FOR THIS KIND, stated rather than implied: a sampler + // CSO is content-addressed and belongs to a value, so no emitter keeps an entry under + // a SamplerObject's handle - the cache's entries are keyed by value and reference + // count, and the death of a bound sampler object releases its unit's reference at the + // next bind_sampler_states pass (SamplerEmit.h's reconciliation). NotifyAndFree(MGPipeKind::SamplerCso, lifetimeId, handle); return published; } @@ -1479,6 +1506,8 @@ namespace MobileGL::MG_Pipe { MGPipeSlots().FindByLifetimeId(MGPipeKind::ShaderCso, lifetimeId); const Bool published = EmitDeleteIfPublished(MGPipeKind::ShaderCso, handle, &MGPipeApplyDeleteShaderState); + ForwardWhenWired( + MGPipeProgramEmitterInstance(), [&](auto& emitter) { emitter.NoteRecordDestroyed(handle); }); NotifyAndFree(MGPipeKind::ShaderCso, lifetimeId, handle); return published; } diff --git a/MobileGL/MG_Impl/Pipe/ProgramEmit.h b/MobileGL/MG_Impl/Pipe/ProgramEmit.h index cebd2a7b..2d0d29a3 100644 --- a/MobileGL/MG_Impl/Pipe/ProgramEmit.h +++ b/MobileGL/MG_Impl/Pipe/ProgramEmit.h @@ -324,14 +324,12 @@ namespace MobileGL::MG_Pipe { // The memo's other half, and the bound-mirror clearing beside it. // - // NO PRODUCTION CALLER TODAY, stated rather than implied: since c0b the death path - // reads the contract's latch and never asks an emitter. It is kept because the memo - // above needs a way to be told, and because everything it clears SELF-HEALS if it is - // not called - the slot's Gen moves on reuse, so `RecordGen == handle.Gen` refuses a - // stale record latch, and the three bound mirrors below hold a handle whose generation - // can never be handed out again, so the next EmitShaderState compares against a - // different handle and re-binds. Clearing them here is the cheaper answer, not the - // load-bearing one. + // THE CALLER IS THE CONTRACT's DEATH HELPER (P4a final review C-2): the death path + // reads the contract's latch for the wire delete and then forwards here, before the + // slot is freed, so a dead handle no longer reads as published in this memo between + // the death and the recycle and the three bound mirrors never name a dead program. + // Gen-keyed, so a late notice for a slot already handed out again clears nothing of + // the successor's. void NoteRecordDestroyed(MGPipeHandle handle) { if (MGPipeHandleIsNull(handle)) return; Vector& table = TableOf(handle); diff --git a/MobileGL/MG_Impl/Pipe/SamplerEmit.h b/MobileGL/MG_Impl/Pipe/SamplerEmit.h index e7a133f6..24fa07e7 100644 --- a/MobileGL/MG_Impl/Pipe/SamplerEmit.h +++ b/MobileGL/MG_Impl/Pipe/SamplerEmit.h @@ -986,11 +986,11 @@ namespace MobileGL::MG_Pipe { } // The memo's other half, for a caller that knows the applier has dropped this record. - // NO PRODUCTION CALLER TODAY, and that is stated rather than implied: the death path - // goes through the contract's latch, not through here. It is kept because the memo - // above needs a way to be told, and because leaving the latch standing SELF-HEALS - // anyway - the slot's Gen moves on reuse, so the `RecordGen == handle.Gen` test in - // RecordIsPublished and in AcquireSamplerView already refuses a stale entry. + // THE CALLER IS THE CONTRACT's DEATH HELPER (P4a final review C-2): the texture's + // helper drops the sampler view minted off the texture's lifetime id and forwards here + // before the slot is freed, so a dead handle no longer reads as published in this memo + // between the death and the recycle. Gen-keyed, so a late notice for a slot already + // handed out again clears nothing of the successor's. void NoteRecordDestroyed(MGPipeHandle handle) { if (MGPipeHandleIsNull(handle)) return; const SizeT slot = handle.Slot; diff --git a/MobileGL/MG_Impl/Pipe/TextureEmit.h b/MobileGL/MG_Impl/Pipe/TextureEmit.h index e7615383..762dd85e 100644 --- a/MobileGL/MG_Impl/Pipe/TextureEmit.h +++ b/MobileGL/MG_Impl/Pipe/TextureEmit.h @@ -441,17 +441,79 @@ namespace MobileGL::MG_Pipe { // The texture a handle names, or null. A RAW pointer is exact here for // MGPipeResourceTracker::Resolve's reason: the entry exists only between the create the - // constructor emits and the destroy the destructor emits, and the Gen compare is what - // refuses a stale handle rather than resolving it to whatever now occupies the slot. + // constructor emits and the destroy the destructor emits - and since the final review's + // C-2 that sentence is ESTABLISHED rather than assumed: the contract's death helper + // forwards to NoteTextureDied below before it frees the slot, so a dead handle finds a + // null pointer here. The Gen compare refuses a RECYCLED handle rather than resolving it + // to whatever now occupies the slot. + // + // A DEAD SLOT IS REFUSED, LOUDLY. The allocator's generation moves only at the NEXT + // hand-out, so between a death and a recycle a dead handle compares equal to the slot's + // generation - which is why the guard is IsLive and not GenOfSlot (the review's C-2: + // that compare guarded a recycled slot and never a dead one, and the drain then called + // a virtual on the freed object once per verb). Reaching this arm at all means a death + // path skipped the emitter, which is a seam defect and not traffic: it is counted and + // logged once, and the answer is null. ITextureObject* ResolveTexture(MGPipeHandle handle) const { const SizeT slot = handle.Slot; if (MGPipeHandleIsNull(handle) || slot >= m_textures.size()) return nullptr; const Entry& entry = m_textures[slot]; if (entry.Texture == nullptr || entry.Gen != handle.Gen) return nullptr; - if (MGPipeSlots().GenOfSlot(MGPipeKind::Texture, handle.Slot) != handle.Gen) return nullptr; + if (!MGPipeSlots().IsLive(MGPipeKind::Texture, handle)) { + ++m_deadResolves; + MGLOG_E_ONCE("MGPipe: texture handle {slot=%u, gen=%u} is dead but the emitter still holds its " + "object - the death path did not retire the entry; refused rather than resolved", + handle.Slot, handle.Gen); + return nullptr; + } return entry.Texture; } + // ---- the death half (P4a final review C-2) ---- + // + // CALLED BY THE CONTRACT'S DEATH HELPER, after the wire delete went out and BEFORE the + // slot is freed (ID-8's order: delete, notice, free - this sits between the first two). + // v2 had no such door: the helper freed the slot, the emitter kept the freed + // ITextureObject* and the level on the drain list, and `glTexImage2D; glDeleteTextures; + // ` walked freed memory at the next validate point - a SIGABRT ("pure virtual + // method called") at the shipping mask. Everything the entry owns goes here: the drain + // entries (nothing is owed for a dead texture - its record is gone with the wire delete), + // the built-in sampler's cache reference (ID-17: one per entry, released at the death + // and no longer at the recycle), the latches and the sticky mask. RetireIfRecycled stays + // as the belt for a slot whose death this emitter was never told about. + // + // Keyed on the GENERATION so a late notice for a slot that has already been handed out + // again cannot retire the successor's entry. + void NoteTextureDied(MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_textures.size()) return; + Entry& entry = m_textures[slot]; + if (entry.Gen != handle.Gen) return; + if (!entry.DrainKeys.empty()) { + // A death inside the drain cannot happen (no SharedPtr drops there), but if one + // ever did the loop below is iterating m_drain: the null pointer the reset + // leaves is what makes EmitOneLevel answer "nothing owed" and drop the entry. + if (!m_draining) { + SizeT kept = 0; + for (SizeT i = 0; i < m_drain.size(); ++i) { + if (m_drain[i].Handle == handle) continue; + m_drain[kept++] = m_drain[i]; + } + m_drain.resize(kept); + } + entry.DrainKeys.clear(); + } + MGPipeSamplerCsoCacheInstance().Release(entry.BuiltinSampler); + entry = Entry{}; + } + void NoteRenderbufferDied(MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_renderbuffers.size()) return; + Entry& entry = m_renderbuffers[slot]; + if (entry.Gen != handle.Gen) return; + entry = Entry{}; + } + // ---- the sticky bind mask (D-A4) ---- // // ORed, never cleared, and emitted on BOTH resource_create and every @@ -474,6 +536,11 @@ namespace MobileGL::MG_Pipe { void NoteTextureBoundAs(MGPipeHandle handle, Uint16 bit) { if (MGPipeHandleIsNull(handle)) return; Entry& entry = EntryFor(m_textures, handle); + // The entry is stamped with the generation it is written under, and a predecessor's + // entry on a recycled slot is retired first (the same door AcquireTexture takes): a + // texture born while the family bit was clear has no create to have done it. + RetireIfRecycled(entry, handle); + entry.Gen = handle.Gen; const Uint16 before = entry.BindMask; const Uint16 now = static_cast(before | bit); if (now == before) return; @@ -491,6 +558,8 @@ namespace MobileGL::MG_Pipe { void NoteRenderbufferBoundAs(MGPipeHandle handle, Uint16 bit) { if (MGPipeHandleIsNull(handle)) return; Entry& entry = EntryFor(m_renderbuffers, handle); + RetireIfRecycled(entry, handle); + entry.Gen = handle.Gen; const Uint16 before = entry.BindMask; const Uint16 now = static_cast(before | bit); if (now == before) return; @@ -706,9 +775,9 @@ namespace MobileGL::MG_Pipe { // what stops the LRU pulling a handle out from under a standing MGPTextureParams // record: the applier deliberately does not resolve BuiltinSampler, and an eviction // is not a parameter change, so nothing would refuse and nothing would re-emit. The - // previous handle is released when the content moves it, and the last one when the - // slot is recycled (RetireIfRecycled) - which is the only moment this package can - // see a texture die, the death helper being A's. + // previous handle is released when the content moves it, and the last one at the + // texture's death (NoteTextureDied, reached from the contract's death helper) - or + // at the recycle, as the belt, for a death this emitter was not told about. MGPipeSamplerCsoCache& cache = MGPipeSamplerCsoCacheInstance(); Uint64 samplerBytes = 0; const MGPipeHandle builtinSampler = @@ -879,6 +948,9 @@ namespace MobileGL::MG_Pipe { // itself returns no byte count - it is not emitted from the validate point's payload // histogram - so this is where the cache's answer lands. Uint64 SamplerCsoPayloadBytes() const { return m_samplerCsoPayloadBytes; } + // Dead handles that still held an object when resolved: a death path that skipped the + // emitter. 0 on a healthy tree; a case that drives every death path asserts it. + Uint64 DeadResolveCount() const { return m_deadResolves; } MGPipeHandle BuiltinSamplerOf(MGPipeHandle handle) const { const SizeT slot = handle.Slot; if (MGPipeHandleIsNull(handle) || slot >= m_textures.size()) return kMGPipeNullHandle; @@ -901,6 +973,7 @@ namespace MobileGL::MG_Pipe { m_creates = m_respecifies = m_paramSets = m_subDatas = 0; m_refusedSubDatas = 0; m_samplerCsoPayloadBytes = 0; + m_deadResolves = 0; } // A unit fixture's per-case reset; the library never calls it. See @@ -968,7 +1041,8 @@ namespace MobileGL::MG_Pipe { } static Uint16 MaskOf(const Vector& table, MGPipeHandle handle) { const SizeT slot = handle.Slot; - return slot < table.size() ? table[slot].BindMask : Uint16{0}; + if (slot >= table.size() || table[slot].Gen != handle.Gen) return Uint16{0}; + return table[slot].BindMask; } // A SLOT THE ALLOCATOR HAS HANDED OUT AGAIN CARRIES ITS PREDECESSOR'S ENTRY, and every // field in it is a lie about the new object (m4). The sticky BindMask is the one that @@ -977,10 +1051,10 @@ namespace MobileGL::MG_Pipe { // the dead one's mask and its first descriptor said so. The generation is what // distinguishes them and the reset is here because AcquireTexture is the one door. // - // IT IS ALSO THE ONLY MOMENT THIS PACKAGE CAN SEE A TEXTURE DIE. The death helper is - // A's (MGPipeEmitTextureDestroyAndFree) and does not forward to this emitter, so the - // built-in sampler's cache reference is dropped here - bounded by the number of live - // texture slots rather than unbounded, which is the shape ID-17 rule 3 names. + // IT IS THE BELT, NOT THE PATH (final review C-2): the death helper forwards to + // NoteTextureDied, which retires the entry - drain entries, cache reference, latches, + // mask - at the death itself. This stays for a slot whose death this emitter was never + // told about, and drops the same reference if one is still standing. void RetireIfRecycled(Entry& entry, MGPipeHandle handle) { if (entry.Gen == handle.Gen) return; MGPipeSamplerCsoCacheInstance().Release(entry.BuiltinSampler); @@ -1200,6 +1274,7 @@ namespace MobileGL::MG_Pipe { Uint64 m_subDatas = 0; Uint64 m_refusedSubDatas = 0; Uint64 m_samplerCsoPayloadBytes = 0; + mutable Uint64 m_deadResolves = 0; }; inline MGPipeTextureEmitter& MGPipeTextureEmitterInstance() { @@ -1234,7 +1309,10 @@ namespace MobileGL::MG_Pipe { // keeping the inline definition here would not have compiled at all; // * step 1 of the death order is inside MGPipeEmitTextureDestroyAndFree / // ...RenderbufferDestroyAndFree, which read MGPipeHandleIsPublished and emit the - // resource_destroy themselves, so the destructors call ONE helper and not two; + // resource_destroy themselves, so the destructors call ONE helper and not two - and + // since the final review's C-2 the helper then forwards to NoteTextureDied / + // NoteRenderbufferDied above, so no ITextureObject* survives its object in this table + // and no dead level survives on the drain list; // * the publication latch is PipeFill.cpp's {kind, slot, gen} table, written by // PublishCreate above and read by those helpers. // diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 31c9849b..8597d0b6 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -824,6 +824,36 @@ gtest_discover_tests(MobileGLIntegrationTest ENVIRONMENT "${MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT}" ) +# P4a final review C-2: the dirty-then-delete case, with the allocator SCRIBBLING every freed +# block. The defect this pins was a client emitter resolving a dead-but-not-recycled texture +# handle to the freed ITextureObject* and calling a virtual on it from the next verb's drain; +# whether that reads the object's ghost or faults depends on what the allocator did with the +# block, so the ambient registrations above run the case as the application would see it and +# these two run it with MALLOC_PERTURB_ set, where a resolved-but-dead pointer faults rather +# than passes. Both backends: the death path is backend-neutral by ruling (ID-8). +mgl_itest_join_environment(MGL_ITEST_GLES_MALLOC_PERTURB_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MALLOC_PERTURB_=165" ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_MALLOC_PERTURB_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MALLOC_PERTURB_=165" ${MGL_ITEST_VULKAN_ENV}) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.MallocPerturb." + TEST_FILTER "P4aFinalFixScenario.ADirtyTextureDeletedBeforeAnyVerbIsWalkedByTheNextDrain" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_MALLOC_PERTURB_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.MallocPerturb." + TEST_FILTER "P4aFinalFixScenario.ADirtyTextureDeletedBeforeAnyVerbIsWalkedByTheNextDrain" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_MALLOC_PERTURB_ENVIRONMENT}" +) + # AsyncCompileScenario, with asynchronous compilation PINNED ON per backend. # # Not a duplicate of what the two ambient registrations already run: they run whatever diff --git a/MobileGL/MG_IntegrationTest/Scenarios/P4aFinalFixScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/P4aFinalFixScenario.cpp index 5be7b055..f24eefca 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/P4aFinalFixScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/P4aFinalFixScenario.cpp @@ -337,5 +337,155 @@ void main() { oColor = texture(uTex, vUv); } glDeleteTextures(1, &cleanup); } + // ====================================================================================== + // C-2: delete-then-use, for every kind P4a mints, on both backends + // ====================================================================================== + + // A level goes dirty, the texture dies before any verb, and the next verb's drain walks + // the entry. Before the fix the emitter resolved the dead handle to the freed object and + // the drain called a virtual on it: SIGABRT in the first round. Eight rounds, and the + // lane registered with MALLOC_PERTURB_ scribbles every freed block so a resolved-but- + // dead pointer faults rather than reads the object's ghost. + TEST_F(P4aFinalFixScenario, ADirtyTextureDeletedBeforeAnyVerbIsWalkedByTheNextDrain) { + if (!Ready()) return; + for (int round = 0; round < 8; ++round) { + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + const std::vector texels = Solid(4, 255, 0, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels.data()); + glBindTexture(GL_TEXTURE_2D, 0); + glDeleteTextures(1, &texture); // the last reference: the frontend object dies here + // Something else is allocated between the death and the drain, so the freed + // storage is not simply re-handed to the next object. + std::vector churn(4096 + round * 1024, static_cast(round)); + (void)churn; + const Image image = DrawSampled(m_other); // the validate point: the drain runs here + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + if (round == 0) Report("ADirtyTextureDeletedBeforeAnyVerbIsWalkedByTheNextDrain", image); + EXPECT_TRUE(Mostly(image, "white", "the draw after a dirty texture died")); + } + } + + // ABA: the slot the dead texture held is handed straight to the next texture (the free + // list is LIFO). The new texture's picture must be its own, and the dead one's drain + // entry must not be replayed onto it. + TEST_F(P4aFinalFixScenario, ATextureRecycledOntoTheDeadSlotDoesNotInheritItsDrainEntry) { + if (!Ready()) return; + { + GLuint dead = 0; + glGenTextures(1, &dead); + glBindTexture(GL_TEXTURE_2D, dead); + const std::vector texels = Solid(4, 255, 0, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels.data()); + glBindTexture(GL_TEXTURE_2D, 0); + glDeleteTextures(1, &dead); // dirty, dead, no verb between + } + const GLuint successor = MakeLevel0(0, 0, 255, /*maxLevel=*/0, /*size=*/8); + const Image image = DrawSampled(successor); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + Report("ATextureRecycledOntoTheDeadSlotDoesNotInheritItsDrainEntry", image); + EXPECT_TRUE(Mostly(image, "blue", "the successor of a dead dirty texture on the recycled slot")); + const Image other = DrawSampled(m_other); + EXPECT_TRUE(Mostly(other, "white", "an unrelated draw after the recycled slot was used")); + GLuint cleanup = successor; + glDeleteTextures(1, &cleanup); + } + + // A renderbuffer with defined storage, attached, cleared through its framebuffer, then + // both die before the next verb. + TEST_F(P4aFinalFixScenario, ARenderbufferAndItsFramebufferDeletedAfterAClearLeaveTheNextDrawIntact) { + if (!Ready()) return; + GLuint renderbuffer = 0; + glGenRenderbuffers(1, &renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 8, 8); + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + glViewport(0, 0, 8, 8); + glClearColor(0.0f, 1.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + const Image cleared = ReadPixels(8, 8); + EXPECT_TRUE(RegionIsMostly(cleared, 0, 8, 0, 8, "green", 0.0, "the renderbuffer after the clear")); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteRenderbuffers(1, &renderbuffer); // the attachment's last reference went with the FBO + const Image image = DrawSampled(m_other); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + Report("ARenderbufferAndItsFramebufferDeletedAfterAClearLeaveTheNextDrawIntact", image); + EXPECT_TRUE(Mostly(image, "white", "the draw after a renderbuffer and its framebuffer died")); + } + + // A sampler object bound to the unit the draw samples through, deleted while bound: GL + // unbinds it from every unit at glDeleteSamplers, and the texture's own parameters apply + // again. Both draws must be the texture's colour. + TEST_F(P4aFinalFixScenario, ASamplerObjectDeletedWhileBoundLeavesTheNextDrawIntact) { + if (!Ready()) return; + const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/0); + GLuint sampler = 0; + glGenSamplers(1, &sampler); + glSamplerParameteri(sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glSamplerParameteri(sampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glBindSampler(0, sampler); + const Image withSampler = DrawSampled(texture); + EXPECT_TRUE(Mostly(withSampler, "red", "the draw through the bound sampler object")); + glDeleteSamplers(1, &sampler); // bound: unbound by the delete, then dies + const Image image = DrawSampled(texture); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + Report("ASamplerObjectDeletedWhileBoundLeavesTheNextDrawIntact", image); + EXPECT_TRUE(Mostly(image, "red", "the draw after the bound sampler object died")); + glBindSampler(0, 0); + GLuint cleanup = texture; + glDeleteTextures(1, &cleanup); + } + + // A second program, in use when it is deleted (GL keeps it alive until it is no longer + // current), then released by a glUseProgram of the fixture's program: it dies there, and + // the draw that follows runs through the survivor. + TEST_F(P4aFinalFixScenario, AProgramDeletedWhileInUseLeavesTheNextDrawIntact) { + if (!Ready()) return; + std::string error; + const GLuint second = CompileProgram(kVS, kFS, &error); + ASSERT_NE(second, 0u) << error; + const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/0); + const Image throughSecond = DrawSampled(texture, second); + EXPECT_TRUE(Mostly(throughSecond, "red", "the draw through the second program")); + glDeleteProgram(second); // current: flagged for deletion, still very much alive + const Image stillCurrent = DrawSampled(texture, second); + EXPECT_TRUE(Mostly(stillCurrent, "red", "the draw through a program flagged for deletion")); + const Image image = DrawSampled(texture); // glUseProgram(m_program): the second dies here + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + Report("AProgramDeletedWhileInUseLeavesTheNextDrawIntact", image); + EXPECT_TRUE(Mostly(image, "red", "the draw after the deleted program was released")); + GLuint cleanup = texture; + glDeleteTextures(1, &cleanup); + } + + // A framebuffer handed to the server BY NAME (a DSA clear emits a Named record, ID-19(c)) + // and deleted before the next verb; its attachment lives on and carries the clear. + TEST_F(P4aFinalFixScenario, AFramebufferDeletedAfterADsaClearLeavesItsAttachmentIntact) { + if (!Ready()) return; + const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/0); + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + const GLfloat green[4] = {0.0f, 1.0f, 0.0f, 1.0f}; + glClearNamedFramebufferfv(fbo, GL_COLOR, 0, green); + glDeleteFramebuffers(1, &fbo); // unbound and named: dies here + const Image image = DrawSampled(texture); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + Report("AFramebufferDeletedAfterADsaClearLeavesItsAttachmentIntact", image); + EXPECT_TRUE(Mostly(image, "green", "the attachment of a framebuffer that died after a DSA clear")); + GLuint cleanup = texture; + glDeleteTextures(1, &cleanup); + } + } // namespace } // namespace MGITest diff --git a/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp b/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp index e2c8902d..d5191edc 100644 --- a/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp @@ -234,7 +234,8 @@ TEST(FramebufferEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { X(FramebufferEmit, ANamedRecordIsSuppressedPerObjectAndNeverAgainstABoundRecord) \ X(FramebufferEmit, ADrawBufferTokenAboveTheWireWidthIsRefusedNotTruncated) \ X(FramebufferEmit, ALayeredCubeAttachmentDoesNotAssertAFaceItCannotKnow) \ - X(FramebufferEmit, EveryNonTexturePointCarriesTheUnknownSentinelsRatherThanZero) + X(FramebufferEmit, EveryNonTexturePointCarriesTheUnknownSentinelsRatherThanZero) \ + X(FramebufferEmit, ADeadFramebuffersNamedRecordLatchIsRetired) #define MGL_DECLARE_PULL_SKIP(Suite, Name) \ TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } @@ -772,6 +773,27 @@ TEST(FramebufferEmit, EveryNonTexturePointCarriesTheUnknownSentinelsRatherThanZe "only moved field is the attachment's texture target would be suppressed"; (void)probe; } +// ============================ final review C-2 ============================ +// +// A framebuffer has no wire lifetime (D-I2), so the only client state under its handle is this +// emitter's per-object Named latch - and the death helper retires it before the slot is freed, +// the shape every P4a kind takes (ID-8). A recycled handle's Gen already refused the stale +// latch, so this pins the hygiene rather than a picture. +TEST(FramebufferEmit, ADeadFramebuffersNamedRecordLatchIsRetired) { + FramebufferScope scope; + MGPipeHandle handle{}; + { + const auto fbo = MakeShared(31); + const auto color = MakeColorTexture(32, 8); + fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D); + handle = MGPipeFramebufferEmitter::HandleFor(*fbo); + ASSERT_GT(Framebuffers().EmitFramebufferByName(*fbo), 0u) << "the Named record did not go out"; + ASSERT_TRUE(Framebuffers().NamedRecordIsLatched(handle)); + } + EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::Framebuffer, handle)); + EXPECT_FALSE(Framebuffers().NamedRecordIsLatched(handle)) + << "the dead framebuffer's Named latch survived its death"; +} #endif // MOBILEGL_PIPE_PUSH // ========================================================================================= diff --git a/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp b/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp index e1c2205e..4b6e087e 100644 --- a/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp @@ -501,7 +501,8 @@ TEST(ProgramEmit, TheProgramRecordSurvivesAMakeCurrentWhileTheThreeBindingsDoNot X(ProgramEmit, AReLinkReIssuesOnTheSameHandle) \ X(ProgramEmit, TheDrawAndDispatchProgramsAreTwoIndependentSlots) \ X(ProgramEmit, AnUnchangedProgramEmitsNothingAtAll) \ - X(ProgramEmit, AReIssuedCreateReSendsTheDefaultUniformBlock) + X(ProgramEmit, AReIssuedCreateReSendsTheDefaultUniformBlock) \ + X(ProgramEmit, ADeadProgramsRecordLatchIsRetiredAtItsDeath) #define MGL_DECLARE_PULL_SKIP(Suite, Name) \ TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } @@ -774,6 +775,26 @@ void main() { gl_Position = vec4(0.0); EmitVertex(); } } EXPECT_GT(Emitter().GlobalConstantsSetCount(), setsBefore); } + // FINAL REVIEW C-2: the death helper forwards to this emitter before the slot is freed, so + // a dead program's handle no longer reads as published in the record memo between the death + // and the recycle (the memo's own Gen test covers only the recycle). + TEST(ProgramEmit, ADeadProgramsRecordLatchIsRetiredAtItsDeath) { + EmitterScope scope; + const GLuint name = MakeVsFsProgram(); + MGPipeHandle handle{}; + { + const SharedPtr& program = Ctx().GetProgramObject(name); + ASSERT_TRUE(program); + Uint64 bytes = 0; + handle = Emitter().AcquireShaderCso(*program, bytes); + ASSERT_FALSE(MGPipeHandleIsNull(handle)); + ASSERT_TRUE(Emitter().RecordIsPublished(handle)); + } + GL::DeleteProgram(name); // not in use: the frontend object dies here + EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, handle)); + EXPECT_FALSE(Emitter().RecordIsPublished(handle)) + << "a dead program still reads as published in the program emitter's memo"; + } } // namespace #endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp b/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp index f65b4743..6d25b401 100644 --- a/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp @@ -219,7 +219,11 @@ TEST(TextureEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { X(TextureEmit, EveryDKTwoDependencyRowGatesItsOwnFamilyAndTheMirrorPairsStayLive) \ X(TextureEmit, ALevelDefinedAfterAnEmittedButUnconsumedUploadKeepsThatUpload) \ X(TextureEmit, AChainTruncationKeepsTheSurvivingLevelsPendingUploads) \ - X(TextureEmit, ARedefinitionOfANonBaseLevelAtANewSizeDropsOnlyThatLevelsPendingUpload) + X(TextureEmit, ARedefinitionOfANonBaseLevelAtANewSizeDropsOnlyThatLevelsPendingUpload) \ + X(TextureEmit, ADeadTexturesHandleResolvesToNothingAndLeavesTheDrainList) \ + X(TextureEmit, ATextureRecycledOntoADeadSlotDoesNotInheritTheDrainEntry) \ + X(TextureEmit, ADeadRenderbuffersEntryIsRetiredWithItsSlot) \ + X(TextureEmit, ADeadTexturesSamplerViewLatchIsRetiredAtItsDeath) #define MGL_DECLARE_PULL_SKIP(Suite, Name) \ TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } @@ -1236,6 +1240,11 @@ TEST(TextureEmit, ARecycledTextureSlotDoesNotInheritItsPredecessorsBindMask) { Textures().NoteTextureBoundAs(firstHandle, kMGPipeBindShaderImage); ASSERT_NE(Textures().TextureBindMask(firstHandle) & kMGPipeBindRenderTarget, 0); } + // Since the final review's C-2 the reference goes back AT THE DEATH, through the death + // helper's forward, and not first at the recycle. + EXPECT_EQ(cache.RefCountOf(firstCso), 0u) + << "the dead texture's cache reference survived its death; the death helper did not reach the emitter"; + EXPECT_EQ(Textures().TextureBindMask(firstHandle), 0u) << "a dead handle still reads its sticky mask"; const auto second = MakeTexture2D(22, 8); const MGPipeHandle secondHandle = Textures().FindTexture(*second); ASSERT_EQ(secondHandle.Slot, firstHandle.Slot) << "the slot was not recycled; the case proves nothing"; @@ -1390,6 +1399,105 @@ TEST(TextureEmit, ARedefinitionOfANonBaseLevelAtANewSizeDropsOnlyThatLevelsPendi EXPECT_FALSE(onePending) << "level 1's 8x8 box survived its redefinition onto a 4x4 level"; } +// ============================ final review C-2 ============================ +// +// A DEAD HANDLE RESOLVES TO NOTHING AND THE DRAIN LIST DROPS IT AT THE DEATH. The allocator's +// generation moves only at the next hand-out, so between a death and a recycle the dead handle +// compared equal to the slot's generation and the emitter answered the freed ITextureObject*; +// the drain then called a virtual on it once per verb until something recycled the slot. +TEST(TextureEmit, ADeadTexturesHandleResolvesToNothingAndLeavesTheDrainList) { + TextureScope scope; + MGPipeHandle handle{}; + { + const auto texture = MakeShared(91); + texture->SetInternalFormat(TextureInternalFormat::RGBA8); + texture->AllocateStorage(TextureUploadTarget::Texture2D, 0, MipmapInput{IntVec3{16, 16, 1}, 16 * 16 * 4}); + // glTexSubImage2D: the level goes on the drain list; NO verb follows before the delete. + texture->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{16, 16, 1}); + handle = Textures().FindTexture(*texture); + ASSERT_FALSE(MGPipeHandleIsNull(handle)); + ASSERT_EQ(Textures().DrainListSize(), 1u); + } // glDeleteTextures: the last SharedPtr drops, ~TextureObjectBase frees the slot + EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::Texture, handle)); + EXPECT_EQ(Textures().ResolveTexture(handle), nullptr) + << "ResolveTexture hands back the freed ITextureObject* of a dead-but-not-recycled handle"; + EXPECT_EQ(Textures().DrainListSize(), 0u) + << "the dead texture's level is still on the drain list, so the next verb walks it"; + // And the next drain has nothing to say about it: no record, no refusal, no emission. + Textures().DrainTextureSubData(Ctx()); + EXPECT_EQ(Textures().SubDataCount(), 0u); + EXPECT_EQ(Textures().RefusedSubDataCount(), 0u); + // The null came from the DEATH PATH retiring the entry, not from the loud refusal that + // guards a death path that skipped the emitter. + EXPECT_EQ(Textures().DeadResolveCount(), 0u) + << "the dead handle was refused by ResolveTexture's guard, so the death helper never told the emitter"; +} + +// The sampler view minted off the texture's lifetime id (D-F2) has its own record memo in the +// sampler emitter; the texture's death retires it through the view's death helper. +TEST(TextureEmit, ADeadTexturesSamplerViewLatchIsRetiredAtItsDeath) { + TextureScope scope; + MGPipeHandle viewHandle{}; + { + const auto texture = MakeTexture2D(89, 8); + const MGPipeHandle handle = Textures().FindTexture(*texture); + Uint64 bytes = 0; + viewHandle = MGPipeSamplerEmitterInstance().AcquireSamplerView(*texture, handle, bytes); + ASSERT_FALSE(MGPipeHandleIsNull(viewHandle)); + ASSERT_TRUE(MGPipeSamplerEmitterInstance().RecordIsPublished(viewHandle)); + } + EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::SamplerViewCso, viewHandle)); + EXPECT_FALSE(MGPipeSamplerEmitterInstance().RecordIsPublished(viewHandle)) + << "a dead sampler view still reads as published in the sampler emitter's memo"; +} + +// ABA: the recycled slot's new texture owns the drain list entry it makes and none of its +// predecessor's. +TEST(TextureEmit, ATextureRecycledOntoADeadSlotDoesNotInheritTheDrainEntry) { + TextureScope scope; + MGPipeHandle deadHandle{}; + { + const auto dead = MakeTexture2D(97, 8); + dead->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{8, 8, 1}); + deadHandle = Textures().FindTexture(*dead); + ASSERT_EQ(Textures().DrainListSize(), 1u); + } + EXPECT_EQ(Textures().DrainListSize(), 0u) << "the death did not retire the drain entry"; + const auto successor = MakeTexture2D(98, 32); + const MGPipeHandle handle = Textures().FindTexture(*successor); + ASSERT_EQ(handle.Slot, deadHandle.Slot) << "the slot was not recycled; the case proves nothing"; + ASSERT_NE(handle.Gen, deadHandle.Gen); + EXPECT_EQ(Textures().DrainListSize(), 0u) << "the successor inherited a drain entry it never made"; + successor->MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, IntVec3{0, 0, 0}, IntVec3{32, 32, 1}); + EXPECT_EQ(Textures().DrainListSize(), 1u); + Textures().DrainTextureSubData(Ctx()); + EXPECT_EQ(Textures().SubDataCount(), 1u) << "exactly the successor's level went out"; + EXPECT_TRUE(Textures().LastSubData().Res == handle); + EXPECT_EQ(Textures().LastSubData().UnionBox.W, 32u); + EXPECT_EQ(Textures().DrainListSize(), 0u); +} + +// The renderbuffer table has no pointer to dangle but the same stale entry: a dead +// renderbuffer's sticky mask must not be readable through its dead handle. +TEST(TextureEmit, ADeadRenderbuffersEntryIsRetiredWithItsSlot) { + TextureScope scope; + MGPipeHandle handle{}; + { + const auto renderbuffer = MakeShared(94); + handle = Textures().FindRenderbuffer(*renderbuffer); + ASSERT_FALSE(MGPipeHandleIsNull(handle)); + Textures().NoteRenderbufferBoundAs(handle, kMGPipeBindRenderTarget); + ASSERT_NE(Textures().RenderbufferBindMask(handle) & kMGPipeBindRenderTarget, 0); + } + EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::Renderbuffer, handle)); + EXPECT_EQ(Textures().RenderbufferBindMask(handle), 0u) + << "a dead renderbuffer's entry still answers through its dead handle"; + const auto successor = MakeShared(99); + const MGPipeHandle successorHandle = Textures().FindRenderbuffer(*successor); + ASSERT_EQ(successorHandle.Slot, handle.Slot); + EXPECT_EQ(Textures().RenderbufferBindMask(successorHandle), 0u); +} + #endif // MOBILEGL_PIPE_PUSH // =========================================================================================