From c6299f754fde8ec23eee456e75e0a29682d3a18f Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Sat, 8 Aug 2026 23:56:53 -0400 Subject: [PATCH] [Fix] (MG_Backend/DirectVulkan, MG_State): key per-object memos on lifetime ids, not heap addresses A destroyed VertexArrayObject's heap address is handed straight back by the next allocation of its size, and so is a destroyed BufferObject's. DirectVulkan keyed its per-VAO draw memo on the VAO POINTER and folded the bound buffer's ADDRESS into the content hash that validates the memoised bindings, so a delete/recreate pair under a byte-identical attribute layout reproduced both the key and its validating hash at once. The successor VAO then inherited the dead one's resolved bindings and the draw fetched from a destroyed VkBuffer. Both stated defences failed together, because both reduce to the content hash and the hash's buffer-identity component was itself a recycled address. VertexArrayObject and BufferObject now carry a globally-unique, never-reused GetLifetimeId() - the same contract as ProgramObject's, minted from an atomic starting at 1 so a zero-initialised slot can never name a live object. VaoDrawMemo matches on (address, lifetime id) and stores the id on recycle, SetupDrawSnapshot's "the VAO did not move" test compares the id alongside the config version, and VertexInputStateFactory::ComputeHash hashes the bound buffer's id instead of its pointer (0 for client memory). Proven: the use-after-free reproduces at 100% incidence headless on lavapipe, including a SEGV whose backtrace is the driver dereferencing a destroyed vertex buffer inside lvp_queue_submit, and it is gone with the fix. New coverage - MG_Test/State/ObjectLifetimeIdTest (deterministic, GPU-free, no context: it waits for the real allocator to repeat an address and asserts the id differs, and skips loudly rather than passing quietly if it never gets the chance), and MG_IntegrationTest XfbAfterClipDistanceScenario, registered for DirectGLES, DirectVulkan, and a third DirectVulkan run with async shader compilation pinned on because that is a second allocation pattern. Gates: 553/553 unit green at async=0 and async=1; the scenario 5/5 headless at both flag states; 71/72 CI trace-replay fixtures over both backends, the one failure a pre-existing lavapipe crash proven not a regression (identical SIGSEGV at the identical call number under the pre-fix library). Pending NVIDIA/X11 confirmation: the KHR-GL{32,40} transform_feedback failures that opened this investigation never reproduced on lavapipe - the -2/-101 pre-fill signature appears in zero pre-fix runs there - so whether this clears them is UNPROVEN and must be re-measured on the NVIDIA rig against a freshly re-run pre-fix baseline. The residual suspect is deliberately untouched here: m_xfbCounterSlotByObject keys its counter slot on the raw GL transform-feedback name, so a recycled name whose generation check happens to pass would RESUME instead of BEGIN. That path was never exercised on lavapipe and is neither confirmed nor exonerated. --- .../Renderer/VertexInputStateFactory.cpp | 20 +- .../Renderer/VertexInputStateFactory.h | 17 +- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 30 +- .../DirectVulkan/Renderer/VulkanRenderer.h | 31 +- MobileGL/MG_IntegrationTest/CMakeLists.txt | 24 + .../XfbAfterClipDistanceScenario.cpp | 584 +++++++++++ .../GLState/BufferState/BufferObject.cpp | 8 + .../GLState/BufferState/BufferObject.h | 10 + .../VertexArrayState/VertexArrayObject.cpp | 11 + .../VertexArrayState/VertexArrayObject.h | 15 + MobileGL/MG_Test/CMakeLists.txt | 3 + MobileGL/MG_Test/Program/CMakeLists.txt | 21 + .../XfbFrontendOrderInvarianceTest.cpp | 984 ++++++++++++++++++ MobileGL/MG_Test/State/CMakeLists.txt | 27 + .../MG_Test/State/ObjectLifetimeIdTest.cpp | 140 +++ 15 files changed, 1894 insertions(+), 31 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp create mode 100644 MobileGL/MG_Test/Program/XfbFrontendOrderInvarianceTest.cpp create mode 100644 MobileGL/MG_Test/State/CMakeLists.txt create mode 100644 MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp index 3f8e79f5..968ac1a2 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp @@ -33,14 +33,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { XXHASH_VERIFY(XXH64_update(m_hashState, &attr.IsBgra, sizeof(attr.IsBgra))); XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Divisor, sizeof(attr.Divisor))); - // The buffer's heap address is an identity component of the key: a freed - // buffer's reused address can alias an old cache entry, but only under a - // byte-identical attribute layout - and the entry payload is a pure function - // of the hashed inputs, with the draw path re-resolving bindingBufferKeys - // against the live VAO attribute pointers, so an aliased hit returns exactly - // what a rebuild would. Address drift only grows the map; the OnFrameBoundary - // aging sweep bounds that. - const SizeT bufferKey = reinterpret_cast(attr.Buffer.get()); + // The bound buffer's IDENTITY is a component of the key, and it has to be the + // buffer's never-reused lifetime id - NOT its heap address, which this used to + // hash. An address is recycled by the allocator, so a deleted-and-recreated + // buffer reproduces it; combined with a byte-identical attribute layout that + // reproduces the WHOLE content hash, and the hash is what + // TryBindResolvedVertexBindings accepts as proof that a memoised binding still + // reads the buffer it was resolved from. It did not: a destroyed buffer's GPU + // slice was bound for its successor's draw, which is how a transform-feedback + // capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous + // test's positions) instead of its own. + // Zero for client memory (no buffer), which is a distinct identity of its own. + const Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0; XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey))); } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h index 5c05f741..6d307eee 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h @@ -28,11 +28,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { struct BackendVertexInputState { HashType hash = 0; // Hash of the resolved Vulkan vertex layout only (bindings, attributes, - // unsupported mask) - NO buffer identities. `hash` mixes buffer heap - // addresses so per-chunk VBOs mint a fresh identity per buffer; keying - // pipelines on that minted one VkPipeline per chunk section for an - // identical layout, defeating pipeline reuse and the per-draw memo. - // Pipelines depend only on the layout, so they key on this instead. + // unsupported mask) - NO buffer identities. `hash` mixes each bound + // buffer's never-reused LIFETIME ID, so per-chunk VBOs mint a fresh + // identity per buffer; keying pipelines on that minted one VkPipeline per + // chunk section for an identical layout, defeating pipeline reuse and the + // per-draw memo. Pipelines depend only on the layout, so they key on this + // instead. HashType layoutHash = 0; // Frame boundary of the last cache hit; entries idle past the // OnFrameBoundary retirement age are evicted (CPU heap only). @@ -86,8 +87,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { const MG_State::GLState::VertexArrayObject& vao, HashType hash); const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao); // Frame boundary hook: ages the cache and evicts entries not hit for many - // frames. The key mixes buffer heap addresses, so buffer/VAO churn keeps - // minting fresh keys; without eviction the map grows for the whole session. + // frames. The key mixes each bound buffer's never-reused lifetime id, so + // buffer/VAO churn keeps minting fresh keys - and does so by construction, + // not by luck: a recreated buffer can no longer land back on its dead + // predecessor's key. Without eviction the map grows for the whole session. // Entries hold no Vulkan handles (pipeline creation copies the descriptions) // and the draw path's entry reference never spans a frame boundary, so // eviction here needs no GPU-idle proof. Self-gated: one counter bump and diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 23be2196..23de5966 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -3201,12 +3201,17 @@ void main() { // carry the most entropy of a multiply. const Uint64 mixed = static_cast(reinterpret_cast(vao) >> 4) * 0x9E3779B97F4A7C15ull; const Uint32 index = static_cast(mixed >> 32) & (kVaoDrawMemoSlotCount - 1); + // The address still picks the slot (it is what the caller has in hand), but it is + // the lifetime id that decides whether the slot is THIS object's: an address on + // its own is recycled, and a slot matched on a recycled address hands the new VAO + // the dead one's resolved bindings. + const Uint64 lifetimeId = vao->GetLifetimeId(); VaoDrawMemo& first = m_vaoDrawMemoTable[index]; - if (first.vaoKey == vao) { + if (first.vaoKey == vao && first.vaoLifetimeId == lifetimeId) { return &first; } VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u]; - if (second.vaoKey == vao) { + if (second.vaoKey == vao && second.vaoLifetimeId == lifetimeId) { return &second; } // Miss: recycle a slot. Prefer an empty one; otherwise evict the entry whose @@ -3217,6 +3222,7 @@ void main() { victim = &second; } victim->vaoKey = vao; + victim->vaoLifetimeId = lifetimeId; victim->contentHash = 0; victim->layoutFactsValid = false; // Unmatchable until a resolve completes (same rule as before: a bailed-out @@ -4475,9 +4481,10 @@ void main() { // vertex-input hash (VAO layout), render-pass hash (render targets + the draw-buffer/format // driven blend & write-mask gating), and the pipeline-state value hash (all fixed-function state). // Reset per-frame and on pipeline destruction so a memoized handle can never dangle. - // The identity hash mixes buffer heap addresses (per-chunk VBOs mint a new - // one per buffer); the memo and the pipeline payload key on the resolved - // LAYOUT hash instead, so draws over identical layouts share one pipeline. + // The identity hash mixes each bound buffer's never-reused lifetime id + // (per-chunk VBOs mint a new one per buffer); the memo and the pipeline + // payload key on the resolved LAYOUT hash instead, so draws over identical + // layouts share one pipeline. // The one-arg fetch rides the VAO's state-pointer memo (no hash, no map). auto& vis = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao); const Uint64 vertexLayoutHash = vis.layoutHash; @@ -5265,7 +5272,8 @@ void main() { // path, re-resolving descriptors and texture layouts nothing invalidated. const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); const Bool vaoMoved = - static_cast(&vao) != snap.vao || vao.GetConfigVersion() != snap.vaoConfigVersion; + static_cast(&vao) != snap.vao || vao.GetLifetimeId() != snap.vaoLifetimeId || + vao.GetConfigVersion() != snap.vaoConfigVersion; const auto& drawFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (static_cast(drawFbo.get()) != snap.drawFbo || @@ -5334,9 +5342,11 @@ void main() { // VAO's content-hash memo. The hash memo shares the cache line this compare // chain already loaded (the config version), and the table slot is compact // and hot - unlike the VAO's aux-memo words, which start a second cold line - // of every object in a VAO-cycling frame. The facts are pure functions of - // the content hash, so a slot whose contentHash equals the live memoised - // hash serves them for ANY VAO object, recycled addresses included. + // of every object in a VAO-cycling frame. The slot only ever answers for + // THIS object: LookupVaoDrawMemo matches (address, lifetime id), so a slot + // a destroyed VAO left behind at a recycled address misses and the facts + // are re-resolved. The contentHash compare is the second gate on top of + // that identity check, catching a reconfiguration of the same live object. Uint64 auxMasks = 0; Bool factsKnown = false; Uint64 contentHash = 0; @@ -5515,6 +5525,7 @@ void main() { snap.renderStateVersion = renderStateVersion; snap.bindGeneration = bindGeneration; snap.vao = static_cast(&vao); + snap.vaoLifetimeId = vao.GetLifetimeId(); snap.vaoConfigVersion = vao.GetConfigVersion(); snap.vaoLayoutHash = vaoLayoutHash; snap.pipeline = pipeline; @@ -5933,6 +5944,7 @@ void main() { snap.programLifetimeId = program.GetLifetimeId(); snap.programVersion = program.GetBackendStateVersion(); snap.vao = &vao; + snap.vaoLifetimeId = vao.GetLifetimeId(); snap.vaoConfigVersion = vao.GetConfigVersion(); snap.drawFbo = drawFbo.get(); snap.fboVersion = drawFbo->GetObjectVersion(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 0fec1674..da96cc20 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -758,6 +758,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 programLifetimeId = 0; Uint32 programVersion = 0; const void* vao = nullptr; + // Same rule as VaoDrawMemo::vaoLifetimeId: (address, config version) is not an + // identity, because a recycled address can arrive carrying a config version + // the dead VAO also had (two mutations to configure one attribute is the + // common shape), and "the VAO did not move" would then skip the layout + // re-resolve for a different VAO. + Uint64 vaoLifetimeId = 0; Uint32 vaoConfigVersion = 0; const void* drawFbo = nullptr; Uint16 fboVersion = 0; @@ -987,19 +993,30 @@ namespace MobileGL::MG_Backend::DirectVulkan { const MG_State::GLState::BufferObject* buffers[kMaxBindings] = {}; Uint64 sliceEpochs[kMaxBindings] = {}; }; - // One direct-mapped slot of the per-VAO draw-memo table below. The key is a - // lookup hint only - a slot is never dereferenced through vaoKey; every fact it - // carries is validated against live state before use: + // One direct-mapped slot of the per-VAO draw-memo table below. A slot belongs to + // the object whose (vaoKey, vaoLifetimeId) pair it carries: the address alone + // only picks the slot, and the never-reused lifetime id is what proves the slot + // is THIS VAO's, so the successor allocated onto a destroyed VAO's address + // always misses. That identity check is load-bearing and the content-hash + // validations below do NOT stand in for it - a recycled address under a + // byte-identical configuration reproduces the content hash exactly, which is + // how a destroyed VAO's resolved bindings were once handed to its successor's + // draw. The slot is still never dereferenced through vaoKey, and every fact it + // carries is still validated against live state before use: // - layoutHash/layoutAuxMasks are valid only while contentHash equals the LIVE // VAO's own hash memo (which the VAO's config version guards), so a config - // change, a buffer rebind, or a recycled VAO address with a different - // configuration all miss. A recycled address with a byte-identical - // configuration AND identical bound buffers reproduces the content hash, and - // then the facts are correct by construction (they are a pure function of it). + // change or a buffer rebind misses even for the same object. // - bindings revalidates per draw exactly as before (frame serial, content // hash, per-binding live buffer pointers and slice epochs). struct alignas(64) VaoDrawMemo { const MG_State::GLState::VertexArrayObject* vaoKey = nullptr; + // The VAO's never-reused lifetime id, checked alongside vaoKey. The pointer + // ALONE is not an identity: a deleted VAO's heap address is handed straight + // back by the next glGenVertexArrays-shaped allocation, and the successor then + // matched this slot and inherited the dead object's memos. Both stated + // defences failed with it, because both reduce to the content hash and the + // content hash's buffer-identity component was itself a recycled heap address. + Uint64 vaoLifetimeId = 0; // The VAO content hash (VertexInputStateFactory::GetOrComputeHash) the two // layout facts below were derived from; 0 while nothing valid is stored. Uint64 contentHash = 0; diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 45a58611..25afbda7 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -51,6 +51,7 @@ add_executable(MobileGLIntegrationTest Scenarios/ResidentIndexScenario.cpp Scenarios/MultiDrawScenario.cpp Scenarios/AsyncCompileScenario.cpp + Scenarios/XfbAfterClipDistanceScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE @@ -217,6 +218,8 @@ mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV}) mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectVulkan" ${MGL_ITEST_VULKAN_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=1" ${MGL_ITEST_VULKAN_ENV}) # TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it. set(MGL_ITEST_TIMEOUT 120) @@ -243,3 +246,24 @@ gtest_discover_tests(MobileGLIntegrationTest TIMEOUT ${MGL_ITEST_TIMEOUT} ENVIRONMENT "${MGL_ITEST_VULKAN_ENVIRONMENT}" ) + +# A third registration, of ONE scenario, with asynchronous shader compilation +# pinned on. Not a second code path in the renderer: a second ALLOCATION pattern. +# The async pipeline's job objects change which of the freed blocks the capture +# phase is handed, and that is what decides whether the destroyed-VAO address is +# reached at all - on the ablated (pre-fix) tree async=1 reproduced 3 runs out of +# 3 where the ambient default reproduced 2 of 3. Pinning it here means the +# high-signal configuration runs whatever the shipped default becomes, instead of +# the suite quietly weakening the day that default flips. It must be process-wide +# (the ENVIRONMENT property), not an in-process scope: the compile pool and its +# threads are stood up at initialization, and their allocations are half the +# point. DirectVulkan only - the memo this pins is DirectVulkan's. +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.AsyncCompile." + TEST_FILTER "XfbAfterClipDistanceScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT}" +) diff --git a/MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp new file mode 100644 index 00000000..6a75a178 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp @@ -0,0 +1,584 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario F - a draw must never read a destroyed object's memoised state. +// +// Distilled from the order-triggered CTS failure: on DirectVulkan, once +// KHR-GLxx.clip_distance.functional had run in the same process, every later +// transform_feedback CAPTURE case failed. It looked like a transform feedback +// bug and is not one. The capture works; the DRAW being captured fetched its +// vertices from the WRONG BUFFER - the one the clip workload had just deleted. +// +// The mechanism, and why the sequence matters. DirectVulkan memoises a VAO's +// resolved Vulkan vertex bindings in a table keyed on the VertexArrayObject's +// heap ADDRESS, validated by a content hash that folds in the bound +// BufferObject's heap ADDRESS. Both are recycled by the allocator, so when the +// workload's VAO and vertex buffer are destroyed and the capture phase's own +// VAO and vertex buffer are allocated onto their addresses under a +// byte-identical attribute layout (one vec4 float array at location 0 - which +// is what both phases use), the key matches, the hash matches, and the memo +// hands the new draw the dead buffer's GPU slice. Nothing about transform +// feedback is involved: capture just makes the wrong vertices legible, because +// the captured record IS the vertex data. The fix gives VertexArrayObject and +// BufferObject never-reused lifetime ids and keys the memo on those. +// +// MOBILEGL_ASYNC_SHADER_COMPILE is not part of the defect. It shifts the +// allocation pattern, so it changes WHICH stop points below land on a recycled +// address - which is why the CTS saw ~100% incidence with it on and ~2% with it +// off, and why the sweep case matters more than any single stop point. +// +// The shapes are the two CTS cases verbatim in structure: +// * the workload is glcClipDistance.cpp FunctionalTest's inner loop (a program +// per (redeclaration, clip count), glEnable(GL_CLIP_DISTANCEi), an FBO per +// primitive type, a draw and a readback), including its early-return +// behaviour: on failure the test returns WITHOUT running its "clip clean" +// loop, so GL_CLIP_DISTANCE0..N-1 stay enabled for the rest of the process. +// That leftover enable state is NOT the carrier (one of the cases below pins +// that); the object churn is. +// * the victim is gl3cTransformFeedback3Tests.cpp's skip_components: a +// gl_SkipComponents capture layout under GL_RASTERIZER_DISCARD, read back +// out of a buffer pre-filled with -1-i so that "captured nothing" is +// distinguishable from "captured the wrong thing". + +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +#ifndef GL_CLIP_DISTANCE0 +#define GL_CLIP_DISTANCE0 0x3000 +#endif + +namespace MGITest { + namespace { + + GLuint CompileShader(GLenum type, const std::string& source, std::string* log) { + const GLuint shader = glCreateShader(type); + const char* text = source.c_str(); + glShaderSource(shader, 1, &text, nullptr); + glCompileShader(shader); + GLint status = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &status); + if (status == GL_FALSE) { + GLint length = 0; + glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length); + std::vector buffer(static_cast(length) + 1, '\0'); + glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data()); + if (log != nullptr) *log = buffer.data(); + glDeleteShader(shader); + return 0; + } + return shader; + } + + // Links a vertex/fragment pair, optionally declaring transform feedback + // varyings first (glTransformFeedbackVaryings takes effect at the next link, + // exactly as the CTS uses it). + GLuint BuildProgram(const std::string& vertexSource, const std::string& fragmentSource, + const std::vector& xfbVaryings, GLenum bufferMode, std::string* log) { + const GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, vertexSource, log); + if (vertexShader == 0) return 0; + const GLuint fragmentShader = CompileShader(GL_FRAGMENT_SHADER, fragmentSource, log); + if (fragmentShader == 0) { + glDeleteShader(vertexShader); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, vertexShader); + glAttachShader(program, fragmentShader); + if (!xfbVaryings.empty()) { + glTransformFeedbackVaryings(program, static_cast(xfbVaryings.size()), xfbVaryings.data(), + bufferMode); + } + glLinkProgram(program); + glDeleteShader(vertexShader); + glDeleteShader(fragmentShader); + GLint status = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &status); + if (status == GL_FALSE) { + GLint length = 0; + glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length); + std::vector buffer(static_cast(length) + 1, '\0'); + glGetProgramInfoLog(program, length + 1, nullptr, buffer.data()); + if (log != nullptr) *log = buffer.data(); + glDeleteProgram(program); + return 0; + } + return program; + } + + // ---------------------------------------------------------------- poison + + // glcClipDistance.cpp FunctionalTest::m_vertex_shader_code with the same + // three substitutions (redeclaration, clip function, array setter). + std::string ClipVertexSource(bool redeclaration, unsigned clipCount, unsigned clipFunction, + unsigned vertexCount) { + const std::string count = std::to_string(clipCount); + std::string source = "#version 400 core\n\n"; + if (redeclaration) { + source += "out float gl_ClipDistance[" + count + "];\n"; + } + source += "\n"; + switch (clipFunction) { + case 0: + source += "float f(int i)\n{\n return 0.0;\n}\n"; + break; + case 1: + source += "float f(int i)\n{\n return 0.25 + 0.75 * (float(i) + 1.0) * (float(gl_VertexID) + 1.0)" + " / (float(" + count + ") * float(" + std::to_string(vertexCount) + "));\n}\n"; + break; + default: + source += "float f(int i)\n{\n return - 0.25 - 0.75 * (float(i) + 1.0) * (float(gl_VertexID) + 1.0)" + " / (float(" + count + ") * float(" + std::to_string(vertexCount) + "));\n}\n"; + break; + } + source += "\nin vec4 position;\n\nvoid main()\n{\n"; + if (redeclaration) { + // Dynamic array setter. + source += " for(int i = 0; i < " + count + "; i++)\n {\n" + " gl_ClipDistance[i] = f(i);\n }\n"; + } else { + // Static array setter, at the highest index this iteration enables. + const std::string index = std::to_string(clipCount - 1); + source += " gl_ClipDistance[" + index + "] = f(" + index + ");\n"; + } + source += "\n gl_Position = position;\n}\n"; + return source; + } + + const char* kClipFragmentSource = R"(#version 400 core + +out vec4 color; + +void main() +{ + color = vec4(1.0, 0.0, 0.0, 1.0); +} +)"; + + // How far into FunctionalTest's loop nest to get before bailing out the way + // the CTS does on a failed check: return immediately, skipping the "clip + // clean" loop that would have disabled GL_CLIP_DISTANCEi again. + struct ClipStopPoint { + unsigned primitiveIndex = 0; // 0 = POINTS, 1 = LINES, 2 = TRIANGLES + unsigned clipFunction = 0; + bool redeclaration = false; + unsigned clipCount = 1; // 1..8, the iteration that "fails" + }; + + // Runs FunctionalTest's loop nest up to and including `stop`, then returns + // leaving exactly the state the CTS leaves behind on a failure. + void RunClipDistanceWorkload(const ClipStopPoint& stop) { + static const GLenum kPrimitiveTypes[] = {GL_POINTS, GL_LINES, GL_TRIANGLES}; + static const GLsizei kPrimitiveIndices[] = {1, 2, 3}; + static const float kPositions[3][12] = { + {0.0f, 0.0f, 0.0f, 1.0f}, + {-1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f}, + {-1.0f, -1.0f, 0.0f, 1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f}, + }; + + for (unsigned primitiveIndex = 0; primitiveIndex <= stop.primitiveIndex; ++primitiveIndex) { + const GLenum primitiveType = kPrimitiveTypes[primitiveIndex]; + const GLsizei vertexCount = kPrimitiveIndices[primitiveIndex]; + const GLsizei framebufferSize = (primitiveType == GL_POINTS) ? 1 : 32; + + GLuint colorBuffer = 0; + GLuint framebuffer = 0; + glGenRenderbuffers(1, &colorBuffer); + glBindRenderbuffer(GL_RENDERBUFFER, colorBuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, framebufferSize, framebufferSize); + glGenFramebuffers(1, &framebuffer); + glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorBuffer); + glViewport(0, 0, framebufferSize, framebufferSize); + + const unsigned lastFunction = + (primitiveIndex == stop.primitiveIndex) ? stop.clipFunction : 2u; + for (unsigned clipFunction = 0; clipFunction <= lastFunction; ++clipFunction) { + const bool atStopFunction = + primitiveIndex == stop.primitiveIndex && clipFunction == stop.clipFunction; + for (unsigned redeclaration = 0; redeclaration < 2; ++redeclaration) { + const bool atStopRedeclaration = + atStopFunction && (redeclaration != 0) == stop.redeclaration; + const unsigned lastCount = atStopRedeclaration ? stop.clipCount : 8u; + for (unsigned clipCount = 1; clipCount <= lastCount; ++clipCount) { + std::string log; + const GLuint program = + BuildProgram(ClipVertexSource(redeclaration != 0, clipCount, clipFunction, + static_cast(vertexCount)), + kClipFragmentSource, {}, GL_INTERLEAVED_ATTRIBS, &log); + if (program == 0) continue; + glUseProgram(program); + + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + glEnable(GL_CLIP_DISTANCE0 + clipCount - 1); + + GLuint vao = 0; + GLuint vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, + static_cast(sizeof(float) * 4 * vertexCount), + kPositions[primitiveIndex], GL_STATIC_DRAW); + const GLint location = glGetAttribLocation(program, "position"); + if (location >= 0) { + glEnableVertexAttribArray(static_cast(location)); + glVertexAttribPointer(static_cast(location), 4, GL_FLOAT, GL_FALSE, 0, + nullptr); + } + + glDrawArrays(primitiveType, 0, vertexCount); + + std::vector pixels( + static_cast(framebufferSize) * framebufferSize * 4, 0); + glReadPixels(0, 0, framebufferSize, framebufferSize, GL_RGBA, GL_UNSIGNED_BYTE, + pixels.data()); + + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindVertexArray(0); + glUseProgram(0); + // MGL_REPRO_KEEPCLIPOBJ leaks the per-iteration objects so + // no GL name and no heap address can be recycled into the + // capture phase. + // Deleting all three is load-bearing, not tidiness: the defect + // this scenario pins needs the VAO's AND its vertex buffer's heap + // addresses to be freed here so the capture phase's own objects + // can be handed the same ones back. + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + glDeleteProgram(program); + + if (atStopRedeclaration && clipCount == stop.clipCount) { + // The CTS's early return: the "clip clean" loop below + // never runs, so the enables survive. + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &framebuffer); + glDeleteRenderbuffers(1, &colorBuffer); + return; + } + } + for (unsigned i = 0; i < 8; ++i) { + glDisable(GL_CLIP_DISTANCE0 + i); + } + } + } + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &framebuffer); + glDeleteRenderbuffers(1, &colorBuffer); + } + } + + // ---------------------------------------------------------------- victim + + // gl3cTransformFeedback3Tests.cpp TransformFeedbackBaseTestCase::m_shader_vert. + const char* kXfbVertexSource = R"(#version 400 core +in vec4 vertex; +out vec4 value1; +out vec4 value2; +out vec4 value3; +out vec4 value4; + +void main (void) +{ + vec4 temp = vertex; + + gl_Position = temp; + + value1 = abs(temp) * 1.0; + value2 = abs(temp) * 2.0; + value3 = abs(temp) * 3.0; + value4 = abs(temp) * 4.0; +} +)"; + + const char* kXfbFragmentSource = R"(#version 400 core +out vec4 color; +void main (void) +{ + color = vec4(0.0, 0.0, 0.0, 1.0); +} +)"; + + // The skip_components capture layout, verbatim. + std::vector SkipComponentsVaryings() { + return {"gl_SkipComponents1", "value1", "gl_SkipComponents2", "gl_SkipComponents1", "value2", + "gl_SkipComponents3", "gl_SkipComponents2", "value3", "gl_SkipComponents4", "value4"}; + } + + constexpr unsigned kSkipComponentCount = 4 * 4 + (1 + 2 + 3 + 4 + 1 + 2); // 16 values + 13 skipped + constexpr unsigned kSkipVertexCount = 6; + + // Runs skip_components and reports what came back. `outCaptured` is the raw + // readback so a failure can say whether anything was written at all. + void RunSkipComponentsCapture(std::vector& outCaptured, std::string* buildLog) { + outCaptured.clear(); + + const GLuint program = BuildProgram(kXfbVertexSource, kXfbFragmentSource, SkipComponentsVaryings(), + GL_INTERLEAVED_ATTRIBS, buildLog); + ASSERT_NE(program, 0u) << "skip_components program failed to link: " << (buildLog ? *buildLog : ""); + glUseProgram(program); + + const std::vector vertices = { + -1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -2.0f, 1.0f, -1.0f, 1.0f, -3.0f, 1.0f, + 1.0f, 1.0f, 4.0f, 1.0f, -1.0f, 1.0f, 5.0f, 1.0f, 1.0f, -1.0f, 6.0f, 1.0f, + }; + + GLuint vao = 0; + GLuint vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, static_cast(sizeof(float) * vertices.size()), vertices.data(), + GL_STATIC_DRAW); + const GLint location = glGetAttribLocation(program, "vertex"); + if (location >= 0) { + glEnableVertexAttribArray(static_cast(location)); + glVertexAttribPointer(static_cast(location), 4, GL_FLOAT, GL_FALSE, 0, nullptr); + } + + const unsigned floatCount = kSkipVertexCount * kSkipComponentCount; + const GLsizeiptr byteSize = static_cast(sizeof(float) * floatCount); + + GLuint captureBuffer = 0; + glGenBuffers(1, &captureBuffer); + glBindBuffer(GL_ARRAY_BUFFER, captureBuffer); + glBufferData(GL_ARRAY_BUFFER, byteSize, nullptr, GL_STATIC_READ); + glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer); + glBindBuffer(GL_ARRAY_BUFFER, 0); + + // The pre-fill that makes "nothing was captured" recognisable. + std::vector prefill(floatCount); + for (unsigned i = 0; i < floatCount; ++i) { + prefill[i] = -1.0f - static_cast(i); + } + glBindBuffer(GL_ARRAY_BUFFER, captureBuffer); + glBufferData(GL_ARRAY_BUFFER, byteSize, prefill.data(), GL_STATIC_DRAW); + glBindBuffer(GL_ARRAY_BUFFER, 0); + + glEnable(GL_RASTERIZER_DISCARD); + glClearColor(0.1f, 0.0f, 0.5f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer); + glBeginTransformFeedback(GL_TRIANGLES); + glDrawArrays(GL_TRIANGLES, 0, static_cast(kSkipVertexCount)); + glEndTransformFeedback(); + glDisable(GL_RASTERIZER_DISCARD); + + outCaptured.resize(floatCount); + glBindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer, 0, byteSize); + const void* mapped = glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, byteSize, GL_MAP_READ_BIT); + if (mapped != nullptr) { + std::memcpy(outCaptured.data(), mapped, static_cast(byteSize)); + glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER); + } + + glDisableVertexAttribArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteBuffers(1, &vbo); + glDeleteBuffers(1, &captureBuffer); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glUseProgram(0); + glDeleteProgram(program); + glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0); + } + + // skip_components' expected buffer: the 13 skipped components keep their + // pre-fill, the 16 captured ones carry |vertex| * n. + std::vector SkipComponentsExpected() { + const std::vector vertices = { + -1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -2.0f, 1.0f, -1.0f, 1.0f, -3.0f, 1.0f, + 1.0f, 1.0f, 4.0f, 1.0f, -1.0f, 1.0f, 5.0f, 1.0f, 1.0f, -1.0f, 6.0f, 1.0f, + }; + const unsigned floatCount = kSkipVertexCount * kSkipComponentCount; + std::vector expected(floatCount); + for (unsigned i = 0; i < floatCount; ++i) { + expected[i] = -1.0f - static_cast(i); + } + // Record layout, in floats: + // [0] skip1 + // [1..4] value1 + // [5..7] skip2 + skip1 + // [8..11] value2 + // [12..16] skip3 + skip2 + // [17..20] value3 + // [21..24] skip4 + // [25..28] value4 + static const unsigned kValueOffsets[4] = {1, 8, 17, 25}; + for (unsigned v = 0; v < kSkipVertexCount; ++v) { + const unsigned base = v * kSkipComponentCount; + for (unsigned value = 0; value < 4; ++value) { + for (unsigned component = 0; component < 4; ++component) { + const float source = vertices[v * 4 + component]; + expected[base + kValueOffsets[value] + component] = + std::fabs(source) * static_cast(value + 1); + } + } + } + return expected; + } + + // Reports the first mismatch, and whether the readback is byte-for-byte the + // pre-fill (i.e. the capture never happened). + ::testing::AssertionResult CheckSkipComponents(const std::vector& captured) { + const std::vector expected = SkipComponentsExpected(); + if (captured.size() != expected.size()) { + return ::testing::AssertionFailure() + << "readback size " << captured.size() << " != " << expected.size(); + } + bool anyWritten = false; + for (std::size_t i = 0; i < captured.size(); ++i) { + if (captured[i] != -1.0f - static_cast(i)) { + anyWritten = true; + break; + } + } + for (std::size_t i = 0; i < expected.size(); ++i) { + if (std::fabs(captured[i] - expected[i]) > 0.0125f) { + return ::testing::AssertionFailure() + << "capture mismatch at index " << i << ": got " << captured[i] << ", expected " + << expected[i] << (anyWritten ? "" : " (the whole buffer is still the pre-fill: " + "NOTHING was captured)"); + } + } + return ::testing::AssertionSuccess(); + } + + // The harness turns "no context came up" into a clean skip, and a skip is + // indistinguishable from a pass in a ctest summary. For this scenario that + // is a hole rather than a courtesy: the defect it pins is DirectVulkan's + // alone, and DirectVulkan now comes up headless on any machine at all - a + // surfaceless EGL platform over a software ICD (lavapipe) is enough. So + // "DirectVulkan did not initialise" here means the run is MISCONFIGURED, + // not that the machine has no GPU, and it must not report green. + // + // Local on purpose: the harness-wide skip semantics are deliberate + // (ScenarioFixture.h states the reasoning), and MOBILEGL_ITEST_REQUIRE_GPU + // is the harness-wide lever for the same intent - but that lever also + // demands a HARDWARE renderer, which is exactly what a lavapipe-only box + // cannot offer. This overrides nothing else: only this scenario, only for + // the backend that can regress, and only for the unusable-harness case. + class XfbAfterClipDistanceScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + // Ready() is false on the base's skip path AND on its REQUIRE_GPU + // failure path; the second one has already failed, so leave it alone + // rather than burying its reason under a second message. + if (Ready() || HasFatalFailure()) return; + if (Gl().BackendName() == "DirectVulkan") { + FAIL() << "DirectVulkan could not be brought up, so the regression this scenario guards - a " + "draw served a destroyed VAO's memoised vertex bindings - was never exercised, and " + "that must be a failure rather than a silent skip. Headless bring-up needs only a " + "Vulkan ICD and a surfaceless EGL platform (a software ICD such as lavapipe " + "qualifies: VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json with " + "EGL_PLATFORM=surfaceless). Harness reason: " + << Gl().SkipReason(); + } + } + }; + + // Control: the capture on its own must work. + TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAlone) { + if (!Ready()) return; + std::vector captured; + std::string log; + RunSkipComponentsCapture(captured, &log); + EXPECT_TRUE(CheckSkipComponents(captured)); + } + + // Bisection step 1: only the leftover GL_CLIP_DISTANCEi enables. + TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceEnables) { + if (!Ready()) return; + for (unsigned i = 0; i < 8; ++i) { + glEnable(GL_CLIP_DISTANCE0 + i); + } + std::vector captured; + std::string log; + RunSkipComponentsCapture(captured, &log); + for (unsigned i = 0; i < 8; ++i) { + glDisable(GL_CLIP_DISTANCE0 + i); + } + EXPECT_TRUE(CheckSkipComponents(captured)); + } + + // Bisection step 2: the whole clip_distance.functional workload, stopped + // where the CTS stopped in the runs that went on to break the capture. + TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceWorkloadLines8) { + if (!Ready()) return; + RunClipDistanceWorkload({.primitiveIndex = 1, .clipFunction = 0, .redeclaration = false, .clipCount = 8}); + std::vector captured; + std::string log; + RunSkipComponentsCapture(captured, &log); + for (unsigned i = 0; i < 8; ++i) { + glDisable(GL_CLIP_DISTANCE0 + i); + } + EXPECT_TRUE(CheckSkipComponents(captured)); + } + + TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceWorkloadPoints1) { + if (!Ready()) return; + RunClipDistanceWorkload({.primitiveIndex = 0, .clipFunction = 0, .redeclaration = true, .clipCount = 1}); + std::vector captured; + std::string log; + RunSkipComponentsCapture(captured, &log); + for (unsigned i = 0; i < 8; ++i) { + glDisable(GL_CLIP_DISTANCE0 + i); + } + EXPECT_TRUE(CheckSkipComponents(captured)); + } + + // A single stop point is not a regression test for this defect: whether the + // capture phase's VAO and vertex buffer land on the addresses the workload just + // freed is a function of how much the workload allocated, so the two cases above + // pin two draws of a lottery. Sweep the grid instead - before the fix, roughly a + // third of these stop points came back holding the workload's vertex data. + TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureSurvivesEveryClipWorkloadStopPoint) { + if (!Ready()) return; + for (unsigned primitiveIndex = 0; primitiveIndex < 3; ++primitiveIndex) { + for (unsigned redeclaration = 0; redeclaration < 2; ++redeclaration) { + for (const unsigned clipCount : {1u, 4u, 8u}) { + RunClipDistanceWorkload({.primitiveIndex = primitiveIndex, + .clipFunction = 0, + .redeclaration = redeclaration != 0, + .clipCount = clipCount}); + std::vector captured; + std::string log; + RunSkipComponentsCapture(captured, &log); + for (unsigned i = 0; i < 8; ++i) { + glDisable(GL_CLIP_DISTANCE0 + i); + } + EXPECT_TRUE(CheckSkipComponents(captured)) + << " (stop point: primitive " << primitiveIndex << ", redeclaration " << redeclaration + << ", clip count " << clipCount << ")"; + } + } + } + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp b/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp index 21bc78eb..8bd8fa61 100644 --- a/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp +++ b/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp @@ -8,9 +8,17 @@ #include "BufferObject.h" +#include + namespace MobileGL::MG_State::GLState { namespace { const BufferBackendOps* g_bufferBackendOps = nullptr; + // Starts at 1 so a zero-initialized cache slot can never carry a live buffer's id. + std::atomic g_nextBufferLifetimeId{1}; + } + + Uint64 BufferObject::AllocateLifetimeId() { + return g_nextBufferLifetimeId.fetch_add(1, std::memory_order_relaxed); } void SetBufferBackendOps(const BufferBackendOps* ops) { diff --git a/MobileGL/MG_State/GLState/BufferState/BufferObject.h b/MobileGL/MG_State/GLState/BufferState/BufferObject.h index af47922e..a1d366eb 100644 --- a/MobileGL/MG_State/GLState/BufferState/BufferObject.h +++ b/MobileGL/MG_State/GLState/BufferState/BufferObject.h @@ -185,6 +185,13 @@ namespace MobileGL { Flags GetMappingAccess() const; GLbitfield GetStorageFlags() const; Uint GetExternalIndex() const; + // Globally-unique, never-reused id for THIS object's lifetime - same contract + // and same motivation as ProgramObject::GetLifetimeId() and + // VertexArrayObject::GetLifetimeId(). A backend that folds a buffer's IDENTITY + // into a cache key must use this, never the GL name (LIFO-recycled by + // glGenBuffers) and never the heap address (recycled by the allocator): both + // let a deleted-and-recreated buffer answer to a dead one's cache entry. + Uint64 GetLifetimeId() const { return m_lifetimeId; } // Monotonic counter bumped on every shadow mutation; backends use it to // validate cached transient slices. Uint64 GetChangeSerial() const; @@ -207,7 +214,10 @@ namespace MobileGL { // SubData transfer to sync the backend's separate GPU copy. void NotifyContentWrite(SizeT offset, SizeT size); + static Uint64 AllocateLifetimeId(); + const Uint m_externalIndex = 0; + const Uint64 m_lifetimeId = AllocateLifetimeId(); SizeT m_size = 0; BufferUsage m_usage = BufferUsage::StaticDraw; // Owns the buffer's bytes (CPU shadow or backend persistent GPU map) and diff --git a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp index a0c0aaa8..a8bbb0ef 100644 --- a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp +++ b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.cpp @@ -8,7 +8,18 @@ #include "VertexArrayObject.h" +#include + namespace MobileGL::MG_State::GLState { + // Starts at 1 so a zero-initialized memo slot can never carry a live object's id. + // Atomic because VAOs are GL-thread-only today but the counter costs nothing to + // make safe, and a duplicate id would resurrect exactly the bug it exists to kill. + static std::atomic s_nextVertexArrayLifetimeId{1}; + + Uint64 VertexArrayObject::AllocateLifetimeId() { + return s_nextVertexArrayLifetimeId.fetch_add(1, std::memory_order_relaxed); + } + VertexArrayObject::VertexArrayObject(Uint externIndex) : m_externalIndex(externIndex) { for (int index = 0; index < MAX_VERTEX_ATTRIBS; ++index) { auto& attr = m_attributes[index]; diff --git a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h index 91d4317d..15af140c 100644 --- a/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h +++ b/MobileGL/MG_State/GLState/VertexArrayState/VertexArrayObject.h @@ -84,6 +84,18 @@ namespace MobileGL { Uint GetExternalIndex() const; + // Globally-unique, never-reused id for THIS object's lifetime - the same + // contract as ProgramObject::GetLifetimeId(), and needed for the same + // reason. Neither the GL name (freed to a LIFO list and handed straight + // back by the next glGenVertexArrays) nor the heap address (freed to the + // allocator and handed straight back by the next allocation of this size) + // can tell a deleted-and-recreated VAO from the original, so a backend + // memo keyed on either one silently inherits the dead object's contents. + // That is not hypothetical: it is what let a transform-feedback capture + // fetch a destroyed VAO's vertex buffer slice (see the VaoDrawMemo key in + // DirectVulkan's VulkanRenderer). + Uint64 GetLifetimeId() const { return m_lifetimeId; } + void SetAttributeDivisor(Uint index, Uint divisor); Uint GetAttributeDivisor(Uint index) const; @@ -185,7 +197,10 @@ namespace MobileGL { return mapping; } + static Uint64 AllocateLifetimeId(); + const Uint m_externalIndex = 0; + const Uint64 m_lifetimeId = AllocateLifetimeId(); Array m_attributes; Array m_attributeVersions; BindingSlot m_indexBufferBindingSlot; diff --git a/MobileGL/MG_Test/CMakeLists.txt b/MobileGL/MG_Test/CMakeLists.txt index fb8a080d..c0edcc40 100644 --- a/MobileGL/MG_Test/CMakeLists.txt +++ b/MobileGL/MG_Test/CMakeLists.txt @@ -66,6 +66,9 @@ gtest_discover_tests(SanityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) add_subdirectory(BackendLoader) add_subdirectory(Buffer) +# The heap-address-is-not-an-identity invariant the backends' per-object memos +# rest on. No GL context, no driver: it only needs the allocator. +add_subdirectory(State) add_subdirectory(EGLState) add_subdirectory(Framebuffer) add_subdirectory(Texture) diff --git a/MobileGL/MG_Test/Program/CMakeLists.txt b/MobileGL/MG_Test/Program/CMakeLists.txt index 2a564f6b..c2748fb6 100644 --- a/MobileGL/MG_Test/Program/CMakeLists.txt +++ b/MobileGL/MG_Test/Program/CMakeLists.txt @@ -92,6 +92,25 @@ target_link_libraries( ${LINK_LIBRARIES} ) +# Its own binary so the "fresh process" isolation level in it is really available +# through --gtest_filter, and so its 60 A->B link pairs cannot perturb another +# suite's per-context caches. +add_executable( + XfbFrontendOrderInvarianceTest + XfbFrontendOrderInvarianceTest.cpp +) + +target_include_directories(XfbFrontendOrderInvarianceTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + XfbFrontendOrderInvarianceTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + # Its own binary on purpose: this one calls MobileGL::Destroy(), and ShaderCompilePool's # stop is a one-way latch for the whole process - every case declared after it in the same # binary would silently run its compiles and links inline. @@ -135,3 +154,5 @@ gtest_discover_tests(ShaderCompileAdoptionTest DISCOVERY_TIMEOUT 60 PROPERTIES L # Same reason: the GL_COMPLETION_STATUS_KHR cases saturate a one-worker pool on purpose. gtest_discover_tests(ParallelShaderCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) +# Same reason again: several cases leave A links outstanding while B compiles and links. +gtest_discover_tests(XfbFrontendOrderInvarianceTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) diff --git a/MobileGL/MG_Test/Program/XfbFrontendOrderInvarianceTest.cpp b/MobileGL/MG_Test/Program/XfbFrontendOrderInvarianceTest.cpp new file mode 100644 index 00000000..ac1e5d52 --- /dev/null +++ b/MobileGL/MG_Test/Program/XfbFrontendOrderInvarianceTest.cpp @@ -0,0 +1,984 @@ +// MobileGL - MobileGL/MG_Test/Program/XfbFrontendOrderInvarianceTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// The frontend's answer for a transform-feedback program must not depend on what +// was linked before it. This binary asserts exactly that, headlessly: it links a +// clip_distance-shaped program A, then an XFB-shaped program B, and diffs B's +// whole frontend output (xfb varyings and their offsets, strides, buffer mode, +// scattered-capture and geometry-strip verdicts, uniform blocks, attribute and +// uniform counts, and every SPIR-V module byte-for-byte plus its Location / +// Component / Index / Offset / XfbBuffer / XfbStride / BuiltIn / Binding / +// DescriptorSet decorations) against the same B linked with no A ahead of it. +// +// It was written to arbitrate an order-triggered CTS failure - after +// KHR-GLxx.clip_distance.functional ran, every later transform_feedback capture +// case failed on DirectVulkan - and its verdict was NEGATIVE, which is what made +// it worth keeping: B's frontend output is bit-identical under every ordering, +// every flag state (MOBILEGL_ASYNC_SHADER_COMPILE on/off, THREADS unset/1/8) and +// every isolation level below. That ruled out the whole frontend - the P0b +// preprocess cache, the stage-6 adoption map, ProgramState, glslang's shared +// built-in symbol tables, the pool workers' thread_locals - and sent the hunt +// downstream, where the defect actually was: DirectVulkan's per-VAO vertex +// binding memo keyed on recycled heap addresses (see +// MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp). Keep it as the +// standing guard on the negative half of that split: if the frontend ever DOES +// acquire cross-program order sensitivity, this is what says so. +// +// Isolation model - three levels, all in one binary: +// * FRESH CONTEXT MG_State::Init() reinstalls pGLContext, which is what +// drops the P0b cache, the adoption map and ProgramState. +// Process globals (glslang tables, prewarm latch, pool +// worker thread_locals) deliberately SURVIVE it, which is +// what makes the fresh-context control a bisection step +// rather than just a reset. +// * FRESH PROCESS ctest runs each gtest case in this binary in the same +// process, so the "control first, poisoned second" and +// "poisoned first, control second" orderings are split +// into two cases whose names sort in opposite orders and +// which each capture BOTH snapshots themselves. A truly +// fresh process is available by running one case with +// --gtest_filter (see the FreshProcess* cases). +// * CACHE-CLEARED context kept, but the source text of B is made unique +// per run so no P0b/adoption hit is possible at all. + +#include + +#include +#include +#include +#include +#include +#include + +#include "Config.h" +#include "Includes.h" +#include "Init.h" +#include "MG_Impl/GLImpl/Getter/GL_Getter.h" +#include "MG_Impl/GLImpl/Program/GL_Program.h" +#include "MG_State/GLState/Core.h" +#include "MG_Util/Async/ShaderCompilePool.h" + +using namespace MobileGL; +using namespace MobileGL::MG_Impl::GLImpl; + +namespace { + + // --------------------------------------------------------------------------------- + // Flag plumbing (same shape AsyncCompileTest uses) + // --------------------------------------------------------------------------------- + class AsyncModeScope { + public: + explicit AsyncModeScope(const Bool async) + : m_saved(MG_Config::Features.AsyncShaderCompile) { + MG_Config::Features.AsyncShaderCompile = + async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff; + } + ~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; } + AsyncModeScope(const AsyncModeScope&) = delete; + AsyncModeScope& operator=(const AsyncModeScope&) = delete; + + private: + const MG_Config::QuirkOverride m_saved; + }; + + // --------------------------------------------------------------------------------- + // A: the clip_distance.functional shape + // glcClipDistance.cpp, FunctionalTest::m_vertex_shader_code with + // CLIP_DISTANCE_REDECLARATION = m_explicit_redeclaration and + // CLIP_DISTANCE_SETUP = m_dynamic_array_setter, clip function 0. + // ${VERSION} for a KHR-GL40 run is "#version 400". + // --------------------------------------------------------------------------------- + String ClipDistanceVs(const int clipCount, const char* version) { + const String n = std::to_string(clipCount); + return String(version) + + "\n" + "\n" + "out float gl_ClipDistance[" + n + "];\n" + "\n" + "float f(int i)\n" + "{\n" + " return 0.0;\n" + "}\n" + "\n" + "in vec4 position;\n" + "\n" + "void main()\n" + "{\n" + " for(int i = 0; i < " + n + "; i++)\n" + " {\n" + " gl_ClipDistance[i] = f(i);\n" + " }\n" + "\n" + " gl_Position = position;\n" + "}\n"; + } + + String ClipDistanceFs(const char* version) { + return String(version) + + "\n" + "\n" + "\n" + "out highp vec4 color;\n" + "\n" + "void main()\n" + "{\n" + " color = vec4(1.0, 0.0, 0.0, 1.0);\n" + "}\n"; + } + + // --------------------------------------------------------------------------------- + // B1: the transform_feedback3 skip_components shape + // gl3cTransformFeedback3Tests.cpp, TransformFeedbackBaseTestCase::m_shader_vert + // at "#version 150", captured with the gl_SkipComponents* varying list. + // This is the case whose failure text is the crispest: + // "compareArrays(GLfloat):index 1 value -2 != 1" + // --------------------------------------------------------------------------------- + String SkipComponentsVs(const char* version, const String& saltComment = String()) { + return String(version) + "\n" + saltComment + + " in vec4 vertex;\n" + " out vec4 value1;\n" + " out vec4 value2;\n" + " out vec4 value3;\n" + " out vec4 value4;\n" + "\n" + " void main (void)\n" + " {\n" + " vec4 temp = vertex;\n" + "\n" + " gl_Position = temp;\n" + "\n" + " value1 = abs(temp) * 1.0;\n" + " value2 = abs(temp) * 2.0;\n" + " value3 = abs(temp) * 3.0;\n" + " value4 = abs(temp) * 4.0;\n" + " }\n"; + } + + String SkipComponentsFs(const char* version) { + return String(version) + + "\n" + " out vec4 fragColor;\n" + " void main (void)\n" + " {\n" + " fragColor = vec4(0.0, 0.0, 0.0, 1.0);\n" + " }\n"; + } + + Vector SkipComponentsVaryings() { + return {"gl_SkipComponents1", "value1", "gl_SkipComponents2", "gl_SkipComponents1", "value2", + "gl_SkipComponents3", "gl_SkipComponents2", "value3", "gl_SkipComponents4", "value4"}; + } + + // --------------------------------------------------------------------------------- + // B2: the capture_vertex_interleaved shape + // gl3cTransformFeedbackTests.cpp, CaptureVertexInterleaved:: + // s_vertex_shader_source_code_template at "#version 130", with + // MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS/4 - 1 user vec4 outputs + // plus gl_Position as the final captured varying. + // --------------------------------------------------------------------------------- + String CaptureInterleavedVs(const int userVaryings, const char* version) { + String declarations; + String setters; + for (int i = 0; i < userVaryings; ++i) { + const String name = "result_" + std::to_string(i); + declarations += "out vec4 " + name + ";\n"; + setters += " " + name + " = vec4(" + std::to_string(i * 4) + ".0, " + + std::to_string(i * 4 + 1) + ".0, " + std::to_string(i * 4 + 2) + ".0, " + + std::to_string(i * 4 + 3) + ".0);\n"; + } + return String(version) + "\n\n" + declarations + "\n" + + "void main()\n" + "{\n" + + setters + + "\n" + " vec4 position = vec4(0.0);\n" + "\n" + " switch(gl_VertexID)\n" + " {\n" + " case 0:\n" + " position = vec4(-1.0 + 0.0625, 1.0 - 0.0625, 0.0, 1.0);\n" + " break;\n" + " case 1:\n" + " position = vec4( 1.0 - 0.0625, 1.0 - 0.0625, 0.0, 1.0);\n" + " break;\n" + " case 2:\n" + " position = vec4(-1.0 + 0.0625, -1.0 + 0.0625, 0.0, 1.0);\n" + " break;\n" + " case 3:\n" + " position = vec4( 1.0 - 0.0625, -1.0 + 0.0625, 0.0, 1.0);\n" + " break;\n" + " }\n" + "\n" + " gl_Position = position;\n" + "}\n"; + } + + String CaptureInterleavedFs(const char* version) { + return String(version) + + "\n" + "\n" + "out vec4 color;\n" + "\n" + "void main()\n" + "{\n" + " color = vec4(0.5);\n" + "}\n"; + } + + Vector CaptureInterleavedVaryings(const int userVaryings) { + Vector names; + for (int i = 0; i < userVaryings; ++i) names.push_back("result_" + std::to_string(i)); + names.push_back("gl_Position"); + return names; + } + + // --------------------------------------------------------------------------------- + // B3: the capture_geometry_interleaved shape. The only shape that reaches + // ResolveGsTriangleStripCapture, i.e. the gsStripTriangles / gsStripCaptureFixup + // artifacts - and triangle_strip is the sub-case that needs the fixup. + // --------------------------------------------------------------------------------- + const char* kGeometryBlankVs = "#version 130\n" + "\n" + "void main()\n" + "{\n" + "}\n"; + + String CaptureGeometryGs(const int userVaryings, const char* outPrimitive) { + String declarations; + String setters; + for (int i = 0; i < userVaryings; ++i) { + const String name = "result_" + std::to_string(i); + declarations += "out vec4 " + name + ";\n"; + setters += " " + name + " = vec4(" + std::to_string(i * 4) + ".0, " + + std::to_string(i * 4 + 1) + ".0, " + std::to_string(i * 4 + 2) + ".0, " + + std::to_string(i * 4 + 3) + ".0);\n"; + } + String source = "#version 150\n" + "\n" + "layout(points) in;\n" + "layout(" + + String(outPrimitive) + + ", max_vertices = 4) out;\n" + "\n" + + declarations + "\n" + + "void main()\n" + "{\n"; + const char* positions[] = {"vec4(-1.0 + 0.0625, 1.0 - 0.0625, 0.0, 1.0)", + "vec4( 1.0 - 0.0625, 1.0 - 0.0625, 0.0, 1.0)", + "vec4(-1.0 + 0.0625, -1.0 + 0.0625, 0.0, 1.0)", + "vec4( 1.0 - 0.0625, -1.0 + 0.0625, 0.0, 1.0)"}; + for (const char* position : positions) { + source += String("\n gl_Position = ") + position + ";\n"; + source += setters; + source += " EmitVertex();\n"; + } + source += "}\n"; + return source; + } + + GLuint BuildProgramWithGeometry(const String& vertexSource, const String& geometrySource, + const String& fragmentSource, const Vector& xfbVaryings); + + // --------------------------------------------------------------------------------- + // SPIR-V digest: hash + every decoration that could express a slot shift, resolved + // through OpName so the text is stable across id renumbering. + // --------------------------------------------------------------------------------- + constexpr Uint32 kOpName = 5; + constexpr Uint32 kOpMemberName = 6; + constexpr Uint32 kOpEntryPoint = 15; + constexpr Uint32 kOpDecorate = 71; + constexpr Uint32 kOpMemberDecorate = 72; + + const char* DecorationName(const Uint32 decoration) { + switch (decoration) { + case 11: return "BuiltIn"; + case 30: return "Location"; + case 31: return "Component"; + case 32: return "Index"; + case 33: return "Binding"; + case 34: return "DescriptorSet"; + case 35: return "Offset"; + case 36: return "XfbBuffer"; + case 37: return "XfbStride"; + case 38: return "FuncParamAttr"; + default: return nullptr; + } + } + + const char* BuiltInName(const Uint32 builtIn) { + switch (builtIn) { + case 0: return "Position"; + case 1: return "PointSize"; + case 3: return "ClipDistance"; + case 4: return "CullDistance"; + case 5: return "VertexId"; + case 42: return "VertexIndex"; + default: return nullptr; + } + } + + String ReadSpirvString(const Vector& words, const SizeT firstWord, const SizeT endWord, + SizeT& outNextWord) { + String text; + SizeT w = firstWord; + for (; w < endWord; ++w) { + const Uint32 word = words[w]; + Bool done = false; + for (int b = 0; b < 4; ++b) { + const char c = static_cast((word >> (8 * b)) & 0xFF); + if (c == '\0') { + done = true; + break; + } + text.push_back(c); + } + if (done) { + ++w; + break; + } + } + outNextWord = w; + return text; + } + + Uint64 Fnv1a(const Vector& words) { + Uint64 hash = 1469598103934665603ULL; + for (const unsigned word : words) { + for (int b = 0; b < 4; ++b) { + hash ^= static_cast((word >> (8 * b)) & 0xFF); + hash *= 1099511628211ULL; + } + } + return hash; + } + + struct SpirvDigest { + Uint64 hash = 0; + SizeT wordCount = 0; + Vector decorations; + Vector interfaceNames; + }; + + SpirvDigest DigestSpirv(const Vector& words) { + SpirvDigest digest; + digest.hash = Fnv1a(words); + digest.wordCount = words.size(); + if (words.size() < 5 || words[0] != 0x07230203u) { + digest.decorations.push_back(""); + return digest; + } + + UnorderedMap names; + Vector interfaceIds; + struct PendingDecoration { + Uint32 target; + Int member; // -1 for OpDecorate + Uint32 decoration; + Vector operands; + }; + Vector pending; + + SizeT w = 5; + while (w < words.size()) { + const Uint32 header = words[w]; + const Uint32 wordCount = header >> 16; + const Uint32 opcode = header & 0xFFFFu; + if (wordCount == 0 || w + wordCount > words.size()) break; + + if (opcode == kOpName && wordCount >= 3) { + SizeT next = 0; + names[words[w + 1]] = ReadSpirvString(words, w + 2, w + wordCount, next); + } else if (opcode == kOpMemberName && wordCount >= 4) { + SizeT next = 0; + const String member = ReadSpirvString(words, w + 3, w + wordCount, next); + names[words[w + 1]] = names.count(words[w + 1]) ? names[words[w + 1]] : String(""); + (void)member; + } else if (opcode == kOpEntryPoint && wordCount >= 4) { + SizeT next = 0; + (void)ReadSpirvString(words, w + 3, w + wordCount, next); + for (SizeT i = next; i < w + wordCount; ++i) interfaceIds.push_back(words[i]); + } else if (opcode == kOpDecorate && wordCount >= 3) { + PendingDecoration entry{words[w + 1], -1, words[w + 2], {}}; + for (SizeT i = w + 3; i < w + wordCount; ++i) entry.operands.push_back(words[i]); + pending.push_back(Move(entry)); + } else if (opcode == kOpMemberDecorate && wordCount >= 4) { + PendingDecoration entry{words[w + 1], static_cast(words[w + 2]), words[w + 3], {}}; + for (SizeT i = w + 4; i < w + wordCount; ++i) entry.operands.push_back(words[i]); + pending.push_back(Move(entry)); + } + w += wordCount; + } + + const auto label = [&](const Uint32 id) { + const auto it = names.find(id); + if (it != names.end() && !it->second.empty()) return it->second; + return String("%") + std::to_string(id); + }; + + for (const auto& entry : pending) { + const char* decorationName = DecorationName(entry.decoration); + if (decorationName == nullptr) continue; // relocation-irrelevant decorations + String line = label(entry.target); + if (entry.member >= 0) line += "[member " + std::to_string(entry.member) + "]"; + line += " "; + line += decorationName; + line += " ="; + for (const Uint32 operand : entry.operands) { + if (entry.decoration == 11) { + const char* builtIn = BuiltInName(operand); + line += String(" ") + (builtIn != nullptr ? builtIn : std::to_string(operand)); + } else { + line += " " + std::to_string(operand); + } + } + digest.decorations.push_back(line); + } + std::sort(digest.decorations.begin(), digest.decorations.end()); + + for (const Uint32 id : interfaceIds) digest.interfaceNames.push_back(label(id)); + std::sort(digest.interfaceNames.begin(), digest.interfaceNames.end()); + return digest; + } + + // --------------------------------------------------------------------------------- + // The snapshot under test + // --------------------------------------------------------------------------------- + struct XfbSnapshot { + GLint linkStatus = GL_FALSE; + String infoLog; + GLenum bufferMode = 0; + Uint32 packedStride = 0; + Bool needsScattered = false; + Int varyingNameMaxLength = 0; + Vector strides; + Vector varyings; + GLenum gsInputPrimitive = 0; + Bool gsStripCaptureFixup = false; + Vector gsStripTriangles; + Int uniformBlockCount = 0; + Vector uniformBlockBindings; + Uint maxUniformLocation = 0; + GLint activeAttributes = 0; + GLint activeUniforms = 0; + Vector spirv; + }; + + String QueryProgramInfoLog(const GLuint program) { + GLint length = 0; + GetProgramiv(program, GL_INFO_LOG_LENGTH, &length); + if (length <= 0) return String(); + std::vector buffer(static_cast(length)); + GLsizei written = 0; + GetProgramInfoLog(program, length, &written, buffer.data()); + return String(buffer.data(), static_cast(written)); + } + + XfbSnapshot Capture(const GLuint program) { + XfbSnapshot snapshot; + GetProgramiv(program, GL_LINK_STATUS, &snapshot.linkStatus); + snapshot.infoLog = QueryProgramInfoLog(program); + + const auto& object = MG_State::pGLContext->GetProgramObject(program); + if (object == nullptr) { + snapshot.infoLog += ""; + return snapshot; + } + snapshot.bufferMode = object->GetTransformFeedbackBufferMode(); + snapshot.packedStride = object->GetTransformFeedbackPackedStride(); + snapshot.needsScattered = object->NeedsScatteredTransformFeedbackCapture(); + snapshot.varyingNameMaxLength = object->GetTransformFeedbackVaryingMaxLength(); + snapshot.gsInputPrimitive = object->GetGeometryInputType(); + snapshot.gsStripCaptureFixup = object->HasGsTriangleStripCaptureFixup(); + snapshot.gsStripTriangles = object->GetGsStripTriangles(); + // The rest of ProgramFactory::ComputeHash's input set, so "the backend cache key is + // unchanged" is something this binary measures rather than assumes. + snapshot.uniformBlockCount = object->GetActiveUniformBlocksCount(); + for (Int i = 0; i < snapshot.uniformBlockCount; ++i) { + snapshot.uniformBlockBindings.push_back(object->GetUniformBlockBinding(static_cast(i))); + } + snapshot.maxUniformLocation = object->GetMaxUniformLocation(); + GetProgramiv(program, GL_ACTIVE_ATTRIBUTES, &snapshot.activeAttributes); + GetProgramiv(program, GL_ACTIVE_UNIFORMS, &snapshot.activeUniforms); + for (SizeT i = 0; i < object->GetTransformFeedbackBufferCount(); ++i) { + snapshot.strides.push_back(object->GetTransformFeedbackStride(static_cast(i))); + } + for (const auto& varying : object->GetTransformFeedbackVaryings()) { + snapshot.varyings.push_back(varying.name + " type=0x" + [&] { + char buffer[16]; + std::snprintf(buffer, sizeof(buffer), "%04X", static_cast(varying.type)); + return String(buffer); + }() + " size=" + std::to_string(varying.size) + " buf=" + std::to_string(varying.bufferIndex) + + " off=" + std::to_string(varying.offsetBytes) + + " bytes=" + std::to_string(varying.byteSize) + + " packedOff=" + std::to_string(varying.packedOffsetBytes)); + } + for (const auto& module : object->GetGeneratedSpirv()) { + snapshot.spirv.push_back(DigestSpirv(module)); + } + return snapshot; + } + + // One text blob per snapshot, so a mismatch shows up as a readable gtest diff. + String Render(const XfbSnapshot& snapshot, const Bool includeSpirvHash) { + String text; + text += "linkStatus = " + std::to_string(snapshot.linkStatus) + "\n"; + if (!snapshot.infoLog.empty()) text += "infoLog = " + snapshot.infoLog + "\n"; + text += "xfbBufferMode = " + std::to_string(snapshot.bufferMode) + "\n"; + text += "xfbPackedStride = " + std::to_string(snapshot.packedStride) + "\n"; + text += "xfbNeedsScatter = " + std::to_string(static_cast(snapshot.needsScattered)) + "\n"; + text += "xfbNameMaxLength = " + std::to_string(snapshot.varyingNameMaxLength) + "\n"; + text += "xfbStrides ="; + for (const Uint32 stride : snapshot.strides) text += " " + std::to_string(stride); + text += "\n"; + text += "xfbVaryings (" + std::to_string(snapshot.varyings.size()) + "):\n"; + for (const String& varying : snapshot.varyings) text += " " + varying + "\n"; + text += "gsInputPrimitive = " + std::to_string(snapshot.gsInputPrimitive) + "\n"; + text += "gsStripFixup = " + std::to_string(static_cast(snapshot.gsStripCaptureFixup)) + "\n"; + text += "gsStripTriangles ="; + for (const Uint32 triangle : snapshot.gsStripTriangles) text += " " + std::to_string(triangle); + text += "\n"; + text += "uniformBlocks = " + std::to_string(snapshot.uniformBlockCount) + " bindings:"; + for (const Uint binding : snapshot.uniformBlockBindings) text += " " + std::to_string(binding); + text += "\n"; + text += "maxUniformLoc = " + std::to_string(snapshot.maxUniformLocation) + "\n"; + text += "activeAttribs = " + std::to_string(snapshot.activeAttributes) + "\n"; + text += "activeUniforms = " + std::to_string(snapshot.activeUniforms) + "\n"; + for (SizeT i = 0; i < snapshot.spirv.size(); ++i) { + const SpirvDigest& digest = snapshot.spirv[i]; + text += "spirv[" + std::to_string(i) + "] words=" + std::to_string(digest.wordCount); + if (includeSpirvHash) { + char buffer[32]; + std::snprintf(buffer, sizeof(buffer), " hash=%016llX", + static_cast(digest.hash)); + text += buffer; + } + text += "\n"; + text += " interface:"; + for (const String& name : digest.interfaceNames) text += " " + name; + text += "\n"; + for (const String& decoration : digest.decorations) text += " " + decoration + "\n"; + } + return text; + } + + // --------------------------------------------------------------------------------- + // Program construction through the real GL entry points + // --------------------------------------------------------------------------------- + GLuint BuildProgram(const String& vertexSource, const String& fragmentSource, + const Vector& xfbVaryings, const GLenum bufferMode) { + const GLuint vertexShader = CreateShader(GL_VERTEX_SHADER); + const char* vertexText = vertexSource.c_str(); + ShaderSource(vertexShader, 1, &vertexText, nullptr); + CompileShader(vertexShader); + + const GLuint fragmentShader = CreateShader(GL_FRAGMENT_SHADER); + const char* fragmentText = fragmentSource.c_str(); + ShaderSource(fragmentShader, 1, &fragmentText, nullptr); + CompileShader(fragmentShader); + + const GLuint program = CreateProgram(); + AttachShader(program, vertexShader); + AttachShader(program, fragmentShader); + if (!xfbVaryings.empty()) { + std::vector names; + names.reserve(xfbVaryings.size()); + for (const String& name : xfbVaryings) names.push_back(name.c_str()); + TransformFeedbackVaryings(program, static_cast(names.size()), names.data(), bufferMode); + } + LinkProgram(program); + DeleteShader(vertexShader); + DeleteShader(fragmentShader); + return program; + } + + // A, exactly as the CTS builds it for the failing sub-case (1 clip distance, dynamic + // setter, clip function 0). Returns the program so the caller can keep it alive, which + // is what the CTS does too (it holds m_program across the whole case). + GLuint LinkClipDistanceProgram(const int clipCount, const char* version) { + return BuildProgram(ClipDistanceVs(clipCount, version), ClipDistanceFs(version), {}, + GL_INTERLEAVED_ATTRIBS); + } + + GLuint LinkSkipComponentsProgram(const char* version, const String& salt = String()) { + return BuildProgram(SkipComponentsVs(version, salt), SkipComponentsFs(version), + SkipComponentsVaryings(), GL_INTERLEAVED_ATTRIBS); + } + + GLuint LinkCaptureInterleavedProgram(const int userVaryings, const char* version) { + return BuildProgram(CaptureInterleavedVs(userVaryings, version), CaptureInterleavedFs(version), + CaptureInterleavedVaryings(userVaryings), GL_INTERLEAVED_ATTRIBS); + } + + GLuint BuildProgramWithGeometry(const String& vertexSource, const String& geometrySource, + const String& fragmentSource, const Vector& xfbVaryings) { + const auto makeShader = [](const GLenum type, const String& source) { + const GLuint shader = CreateShader(type); + const char* text = source.c_str(); + ShaderSource(shader, 1, &text, nullptr); + CompileShader(shader); + return shader; + }; + const GLuint vertexShader = makeShader(GL_VERTEX_SHADER, vertexSource); + const GLuint geometryShader = makeShader(GL_GEOMETRY_SHADER, geometrySource); + const GLuint fragmentShader = makeShader(GL_FRAGMENT_SHADER, fragmentSource); + + const GLuint program = CreateProgram(); + AttachShader(program, vertexShader); + AttachShader(program, geometryShader); + AttachShader(program, fragmentShader); + std::vector names; + names.reserve(xfbVaryings.size()); + for (const String& name : xfbVaryings) names.push_back(name.c_str()); + TransformFeedbackVaryings(program, static_cast(names.size()), names.data(), + GL_INTERLEAVED_ATTRIBS); + LinkProgram(program); + DeleteShader(vertexShader); + DeleteShader(geometryShader); + DeleteShader(fragmentShader); + return program; + } + + GLuint LinkCaptureGeometryProgram(const int userVaryings, const char* outPrimitive) { + return BuildProgramWithGeometry(kGeometryBlankVs, CaptureGeometryGs(userVaryings, outPrimitive), + CaptureInterleavedFs("#version 130"), + CaptureInterleavedVaryings(userVaryings)); + } + + // Reinstalls pGLContext: new ProgramState, new P0b preprocess cache, new stage-6 + // adoption map. glslang's process globals are untouched on purpose. + void FreshContext() { MG_State::Init(); } + + class XfbFrontendOrderInvarianceTest : public ::testing::Test { + protected: + void SetUp() override { MobileGL::Initialize(); } + void TearDown() override { FreshContext(); } + }; + + // The two B shapes, run through one lambda so every case tests both. + struct BCase { + const char* label; + GLuint (*link)(); + }; + + GLuint LinkSkip150() { return LinkSkipComponentsProgram("#version 150"); } + GLuint LinkCapture130() { return LinkCaptureInterleavedProgram(15, "#version 130"); } + GLuint LinkSkip400() { return LinkSkipComponentsProgram("#version 400"); } + GLuint LinkCapture400() { return LinkCaptureInterleavedProgram(15, "#version 400"); } + + GLuint LinkGeometryPoints() { return LinkCaptureGeometryProgram(15, "points"); } + GLuint LinkGeometryTriangleStrip() { return LinkCaptureGeometryProgram(15, "triangle_strip"); } + + const BCase kBCases[] = { + {"skip_components@150", &LinkSkip150}, + {"capture_interleaved@130", &LinkCapture130}, + {"skip_components@400", &LinkSkip400}, + {"capture_interleaved@400", &LinkCapture400}, + {"capture_geometry@points", &LinkGeometryPoints}, + {"capture_geometry@triangle_strip", &LinkGeometryTriangleStrip}, + }; + + // --------------------------------------------------------------------------------- + // The core A/B comparison, parameterized on everything that could matter. + // --------------------------------------------------------------------------------- + struct AbResult { + String control; + String poisoned; + }; + + AbResult RunAb(const BCase& bCase, const int clipCount, const char* clipVersion, + const Bool freshContextForControl, const Bool includeSpirvHash) { + AbResult result; + + // CONTROL: B alone, in a context that has never seen A. + if (freshContextForControl) FreshContext(); + { + const GLuint program = bCase.link(); + result.control = Render(Capture(program), includeSpirvHash); + DeleteProgram(program); + } + + // POISONED: A first, then B, in ONE context - the glcts shape. + FreshContext(); + { + const GLuint clipProgram = LinkClipDistanceProgram(clipCount, clipVersion); + GLint clipLinked = GL_FALSE; + GetProgramiv(clipProgram, GL_LINK_STATUS, &clipLinked); + // The A program is deliberately kept alive across B's link, exactly as the CTS + // holds its program object for the duration of the case. + const GLuint program = bCase.link(); + result.poisoned = Render(Capture(program), includeSpirvHash); + if (clipLinked != GL_TRUE) { + result.poisoned += "\n<<< A DID NOT LINK: " + QueryProgramInfoLog(clipProgram) + " >>>\n"; + } + DeleteProgram(program); + DeleteProgram(clipProgram); + } + return result; + } + +} // namespace + +// ------------------------------------------------------------------------------------- +// 1. The headline question, both flag states, both B shapes, several clip counts. +// ------------------------------------------------------------------------------------- +TEST_F(XfbFrontendOrderInvarianceTest, AsyncOn_ClipDistanceBeforeXfbChangesNothingInTheFrontend) { + const AsyncModeScope async(true); + ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled()); + + for (const BCase& bCase : kBCases) { + for (const int clipCount : {1, 4, 8}) { + for (const char* clipVersion : {"#version 400", "#version 150", "#version 130"}) { + const AbResult result = RunAb(bCase, clipCount, clipVersion, true, true); + EXPECT_EQ(result.control, result.poisoned) + << "async=1 B=" << bCase.label << " clipCount=" << clipCount + << " clipVersion=" << clipVersion; + } + } + } +} + +TEST_F(XfbFrontendOrderInvarianceTest, AsyncOff_ClipDistanceBeforeXfbChangesNothingInTheFrontend) { + const AsyncModeScope async(false); + + for (const BCase& bCase : kBCases) { + for (const int clipCount : {1, 4, 8}) { + for (const char* clipVersion : {"#version 400", "#version 150", "#version 130"}) { + const AbResult result = RunAb(bCase, clipCount, clipVersion, true, true); + EXPECT_EQ(result.control, result.poisoned) + << "async=0 B=" << bCase.label << " clipCount=" << clipCount + << " clipVersion=" << clipVersion; + } + } + } +} + +// ------------------------------------------------------------------------------------- +// 2. Repetition: the CTS incidence with async off is ~2.4%, i.e. roughly 1 in 40 runs, so +// a single comparison would miss it. 60 repetitions of the same A->B pair inside one +// process, each with its own fresh context, is the headless equivalent. +// ------------------------------------------------------------------------------------- +TEST_F(XfbFrontendOrderInvarianceTest, RepeatedAbPairsAreBitStable) { + for (const Bool async : {true, false}) { + const AsyncModeScope scope(async); + String reference; + for (int repetition = 0; repetition < 60; ++repetition) { + const AbResult result = RunAb(kBCases[0], 1, "#version 400", repetition == 0, true); + if (repetition == 0) { + reference = result.control; + ASSERT_EQ(reference, result.poisoned) << "async=" << async << " first repetition"; + } + EXPECT_EQ(reference, result.poisoned) << "async=" << async << " repetition " << repetition; + } + } +} + +// ------------------------------------------------------------------------------------- +// 3. Same context, no reset between A and B, and B's source made unique so neither the +// P0b preprocess cache nor the stage-6 adoption map can serve it. If the divergence +// survives this, no per-source memo is carrying it. +// ------------------------------------------------------------------------------------- +TEST_F(XfbFrontendOrderInvarianceTest, NoMemoHitPossibleForB) { + for (const Bool async : {true, false}) { + const AsyncModeScope scope(async); + + FreshContext(); + const GLuint controlProgram = LinkSkipComponentsProgram("#version 150", "// salt control\n"); + const String control = Render(Capture(controlProgram), false); + DeleteProgram(controlProgram); + + FreshContext(); + const GLuint clipProgram = LinkClipDistanceProgram(1, "#version 400"); + const GLuint poisonedProgram = LinkSkipComponentsProgram("#version 150", "// salt poisoned\n"); + const String poisoned = Render(Capture(poisonedProgram), false); + DeleteProgram(poisonedProgram); + DeleteProgram(clipProgram); + + EXPECT_EQ(control, poisoned) << "async=" << async << " (SPIR-V hash excluded: the salt comment " + "is stripped by the preprocessor but ids can still renumber)"; + } +} + +// ------------------------------------------------------------------------------------- +// 4. Bisection: A and B in one context WITHOUT the reset in between, so ProgramState, the +// P0b cache and the adoption map all carry over exactly as they do in glcts, compared +// against A and B separated by a fresh context. A difference here but not in case 1 +// would put the poison in per-context state; no difference in either puts it outside +// the frontend entirely. +// ------------------------------------------------------------------------------------- +TEST_F(XfbFrontendOrderInvarianceTest, PerContextStateBisection) { + for (const Bool async : {true, false}) { + const AsyncModeScope scope(async); + + // (a) A, fresh context, then B: per-context state cleared, process globals kept. + FreshContext(); + const GLuint clipA = LinkClipDistanceProgram(1, "#version 400"); + DeleteProgram(clipA); + FreshContext(); + const GLuint separated = LinkSkipComponentsProgram("#version 150"); + const String separatedText = Render(Capture(separated), true); + DeleteProgram(separated); + + // (b) A then B, same context, A kept alive. + FreshContext(); + const GLuint clipB = LinkClipDistanceProgram(1, "#version 400"); + const GLuint together = LinkSkipComponentsProgram("#version 150"); + const String togetherText = Render(Capture(together), true); + DeleteProgram(together); + DeleteProgram(clipB); + + EXPECT_EQ(separatedText, togetherText) << "async=" << async; + } +} + +// ------------------------------------------------------------------------------------- +// 5. The interleaving glcts actually produces: many cases in a row, A somewhere in the +// middle, every B compared against the very first B. This is the one that catches a +// poison that needs more than one link to develop. +// ------------------------------------------------------------------------------------- +TEST_F(XfbFrontendOrderInvarianceTest, LongCaseSequenceLikeGlcts) { + for (const Bool async : {true, false}) { + const AsyncModeScope scope(async); + FreshContext(); + + String reference; + Vector keepAlive; + for (int step = 0; step < 12; ++step) { + if (step == 4) { + // The clip_distance case: every clip count, both setters' shapes. + for (const int clipCount : {1, 2, 4, 8}) { + keepAlive.push_back(LinkClipDistanceProgram(clipCount, "#version 400")); + } + } + const GLuint program = LinkSkipComponentsProgram("#version 150"); + const String text = Render(Capture(program), true); + if (step == 0) { + reference = text; + } else { + EXPECT_EQ(reference, text) << "async=" << async << " step " << step; + } + keepAlive.push_back(program); + } + for (const GLuint program : keepAlive) DeleteProgram(program); + } +} + +// ------------------------------------------------------------------------------------- +// 6. Fresh-process controls. Run exactly one of these with --gtest_filter to get a +// process that has linked nothing else, then diff the two printed blobs by hand: +// ./XfbFrontendOrderInvarianceTest --gtest_filter='*FreshProcessControlB*' +// ./XfbFrontendOrderInvarianceTest --gtest_filter='*FreshProcessAThenB*' +// Both print their snapshot to stdout; they never fail on their own. +// ------------------------------------------------------------------------------------- +// ------------------------------------------------------------------------------------- +// 7. The one shape only async can produce: A's link is still IN FLIGHT when B's shaders +// are compiled and B is linked. Nothing joins A until after B has published. If the +// poison rode a worker thread_local (glslang's pool allocator, its TLS parse context) +// rather than any per-context container, this is where it would show. +// N copies of A are enqueued first so the pool really has a backlog. +// ------------------------------------------------------------------------------------- +TEST_F(XfbFrontendOrderInvarianceTest, BLinksWhileAIsStillInFlight) { + const AsyncModeScope async(true); + ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled()); + + FreshContext(); + const GLuint controlProgram = LinkSkipComponentsProgram("#version 150"); + const String control = Render(Capture(controlProgram), true); + DeleteProgram(controlProgram); + + for (int repetition = 0; repetition < 20; ++repetition) { + FreshContext(); + Vector clipPrograms; + // Enqueued, never read: every one of these links is outstanding while B goes + // through compile + link on the same pool. + for (const int clipCount : {1, 2, 3, 4, 5, 6, 7, 8}) { + clipPrograms.push_back(LinkClipDistanceProgram(clipCount, "#version 400")); + } + const GLuint program = LinkSkipComponentsProgram("#version 150"); + const String poisoned = Render(Capture(program), true); + EXPECT_EQ(control, poisoned) << "repetition " << repetition; + DeleteProgram(program); + for (const GLuint clipProgram : clipPrograms) DeleteProgram(clipProgram); + } +} + +// Sanity: every shape this binary compares must actually LINK, otherwise "control == +// poisoned" is the trivially true statement that two failures look alike. +TEST_F(XfbFrontendOrderInvarianceTest, EveryShapeActuallyLinks) { + const AsyncModeScope async(true); + for (const int clipCount : {1, 2, 4, 8}) { + for (const char* version : {"#version 400", "#version 150", "#version 130"}) { + FreshContext(); + const GLuint program = LinkClipDistanceProgram(clipCount, version); + GLint linked = GL_FALSE; + GetProgramiv(program, GL_LINK_STATUS, &linked); + EXPECT_EQ(linked, GL_TRUE) << "A clipCount=" << clipCount << " " << version << ": " + << QueryProgramInfoLog(program); + DeleteProgram(program); + } + } + for (const BCase& bCase : kBCases) { + FreshContext(); + const GLuint program = bCase.link(); + GLint linked = GL_FALSE; + GetProgramiv(program, GL_LINK_STATUS, &linked); + EXPECT_EQ(linked, GL_TRUE) << "B " << bCase.label << ": " << QueryProgramInfoLog(program); + std::printf("=== B shape %s ===\n%s\n", bCase.label, Render(Capture(program), true).c_str()); + DeleteProgram(program); + } +} + +// Writes B's raw SPIR-V modules next to the binary so they can be run through spirv-dis +// by hand. MOBILEGL_XFB_INVARIANCE_DUMP_DIR selects the directory; unset means no dump. +TEST_F(XfbFrontendOrderInvarianceTest, DumpBSpirvForDisassembly) { + const char* directory = std::getenv("MOBILEGL_XFB_INVARIANCE_DUMP_DIR"); + if (directory == nullptr) { + GTEST_SKIP() << "set MOBILEGL_XFB_INVARIANCE_DUMP_DIR to dump"; + } + const AsyncModeScope async(true); + struct Dump { + const char* tag; + Bool withClipDistanceFirst; + }; + for (const Dump& dump : {Dump{"control", false}, Dump{"poisoned", true}}) { + for (const BCase& bCase : kBCases) { + FreshContext(); + GLuint clipProgram = 0; + if (dump.withClipDistanceFirst) clipProgram = LinkClipDistanceProgram(1, "#version 400"); + const GLuint program = bCase.link(); + const auto& object = MG_State::pGLContext->GetProgramObject(program); + const auto& modules = object->GetGeneratedSpirv(); + for (SizeT i = 0; i < modules.size(); ++i) { + String path = String(directory) + "/" + dump.tag + "-" + bCase.label + "-" + + std::to_string(i) + ".spv"; + std::replace(path.begin() + std::strlen(directory) + 1, path.end(), '@', '_'); + std::FILE* file = std::fopen(path.c_str(), "wb"); + ASSERT_NE(file, nullptr) << path; + std::fwrite(modules[i].data(), sizeof(unsigned), modules[i].size(), file); + std::fclose(file); + } + DeleteProgram(program); + if (clipProgram != 0) DeleteProgram(clipProgram); + } + } +} + +TEST_F(XfbFrontendOrderInvarianceTest, FreshProcessControlB) { + const AsyncModeScope async(true); + const GLuint program = LinkSkipComponentsProgram("#version 150"); + std::printf("=== FreshProcessControlB ===\n%s\n", Render(Capture(program), true).c_str()); + DeleteProgram(program); +} + +TEST_F(XfbFrontendOrderInvarianceTest, FreshProcessAThenB) { + const AsyncModeScope async(true); + const GLuint clipProgram = LinkClipDistanceProgram(1, "#version 400"); + const GLuint program = LinkSkipComponentsProgram("#version 150"); + std::printf("=== FreshProcessAThenB ===\n%s\n", Render(Capture(program), true).c_str()); + DeleteProgram(program); + DeleteProgram(clipProgram); +} diff --git a/MobileGL/MG_Test/State/CMakeLists.txt b/MobileGL/MG_Test/State/CMakeLists.txt new file mode 100644 index 00000000..bb8e5900 --- /dev/null +++ b/MobileGL/MG_Test/State/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.14) + +add_executable( + ObjectLifetimeIdTest + ObjectLifetimeIdTest.cpp +) + +target_include_directories(ObjectLifetimeIdTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries( + ObjectLifetimeIdTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(ObjectLifetimeIdTest PRIVATE /Zc:preprocessor) +endif() + +include(GoogleTest) +gtest_discover_tests(ObjectLifetimeIdTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp b/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp new file mode 100644 index 00000000..3290be28 --- /dev/null +++ b/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp @@ -0,0 +1,140 @@ +// MobileGL - MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// The invariant every backend memo keyed on a state object now rests on: a heap +// ADDRESS is not an identity, a lifetime id is. +// +// DirectVulkan memoises resolved vertex bindings per VertexArrayObject and folds +// the bound BufferObject's identity into the content hash that validates them. +// Both used to be heap addresses, and the allocator hands a freed address +// straight back: a VAO and a vertex buffer destroyed and immediately recreated +// under a byte-identical attribute layout reproduced BOTH the memo key and the +// validating hash, so the new draw fetched the destroyed buffer's GPU slice. +// GetLifetimeId() is what makes that impossible, so it is worth a test that +// needs no GPU, no context and no driver - only the allocator. +// +// The test does not simulate reuse; it waits for the real allocator to do it +// (which a LIFO free-list does on the very next allocation) and then asserts the +// id differs. If the allocator never repeats an address the run proves nothing, +// and the case says so with a skip rather than passing quietly. + +#include + +#include +#include +#include + +#include "Includes.h" + +#include +#include + +using namespace MobileGL; + +namespace { + + // The allocation must actually happen: C++ permits eliding a new/delete pair, + // and an elided one would let two objects share an address for reasons that + // have nothing to do with the allocator - which is the only thing under test + // here. Publishing every pointer through a volatile sink keeps the pairs. + void* volatile g_addressSink = nullptr; + + // Constructs and destroys `ObjectT` on the heap kAttempts times, watching for + // the allocator to hand back an address it already used. Every repeat must + // carry a lifetime id the dead occupant did not have. Returns how many repeats + // were seen, so the caller can tell "proven" from "never got the chance". + // + // Each object type has its own id counter, so a VertexArrayObject and a + // BufferObject may well both be id 1; ids are only ever compared within a + // type, which is exactly how the memos use them. + template + int ProbeLifetimeIdAcrossAddressReuse(const char* typeName) { + constexpr int kAttempts = 64; + + std::unordered_map idAtAddress; + int reuseCount = 0; + Uint64 previousId = 0; + + for (int attempt = 0; attempt < kAttempts; ++attempt) { + auto object = std::make_unique(0u); + g_addressSink = object.get(); + const auto address = reinterpret_cast(object.get()); + const Uint64 lifetimeId = object->GetLifetimeId(); + + // 0 is the "this slot holds nothing" value in every memo that stores an + // id, so a live object must never be able to answer to a zeroed slot. + EXPECT_NE(lifetimeId, 0u) << typeName << " handed out lifetime id 0 (attempt " << attempt + << "), which is the value a zero-initialised memo slot already carries"; + EXPECT_GT(lifetimeId, previousId) + << typeName << " lifetime ids must be strictly increasing, so an id is never handed out twice " + << "(attempt " << attempt << ")"; + previousId = lifetimeId; + + const auto inserted = idAtAddress.emplace(address, lifetimeId); + if (!inserted.second) { + // The allocator reproduced an address: this is precisely the state in + // which a memo keyed on the address alone would hit a dead object's + // entry. The id is the thing that has to say no. + ++reuseCount; + EXPECT_NE(lifetimeId, inserted.first->second) + << typeName << " reconstructed at the address of a destroyed one reports the DEAD object's " + << "lifetime id - a backend memo keyed on it would serve the dead object's resolved state " + << "to this object's draws (attempt " << attempt << ")"; + inserted.first->second = lifetimeId; + } + + // Freed before the next construction on purpose: that ordering is what + // makes the allocator reuse the block, and it is the ordering the GL + // workload has (glDeleteVertexArrays, then the next glGenVertexArrays). + object.reset(); + } + + return reuseCount; + } + + // Guards against a degenerate "id" that is really just the address in disguise: + // objects alive at the same time must differ too. + template + void ExpectDistinctIdsWhileBothAlive(const char* typeName) { + auto first = std::make_unique(0u); + auto second = std::make_unique(0u); + g_addressSink = first.get(); + g_addressSink = second.get(); + EXPECT_NE(first->GetLifetimeId(), second->GetLifetimeId()) + << "two live " << typeName << "s share a lifetime id"; + } + +} // namespace + +TEST(ObjectLifetimeIdTest, VertexArrayObjectAtARecycledAddressCarriesAFreshLifetimeId) { + using MG_State::GLState::VertexArrayObject; + const int reuseCount = ProbeLifetimeIdAcrossAddressReuse("VertexArrayObject"); + if (reuseCount == 0) { + GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 " + "construct/destroy rounds, so the recycled-address case was never exercised"; + } + RecordProperty("address_reuses_observed", reuseCount); +} + +TEST(ObjectLifetimeIdTest, BufferObjectAtARecycledAddressCarriesAFreshLifetimeId) { + using MG_State::GLState::BufferObject; + const int reuseCount = ProbeLifetimeIdAcrossAddressReuse("BufferObject"); + if (reuseCount == 0) { + GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 " + "construct/destroy rounds, so the recycled-address case was never exercised"; + } + RecordProperty("address_reuses_observed", reuseCount); +} + +TEST(ObjectLifetimeIdTest, LiveVertexArrayObjectsHaveDistinctLifetimeIds) { + ExpectDistinctIdsWhileBothAlive("VertexArrayObject"); +} + +TEST(ObjectLifetimeIdTest, LiveBufferObjectsHaveDistinctLifetimeIds) { + ExpectDistinctIdsWhileBothAlive("BufferObject"); +}