diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index c8100d56..89238e6f 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -129,6 +129,8 @@ add_executable(MobileGLIntegrationTest Scenarios/DualSourceBlendScenario.cpp Scenarios/PipeVerifyArmingScenario.cpp Scenarios/PoisonOmissionScenario.cpp + Scenarios/HandleRecycleScenario.cpp + Scenarios/CsoContentAddressingScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE @@ -318,6 +320,75 @@ function(mgl_itest_join_environment outVar) set(${outVar} "${joined}" PARENT_SCOPE) endfunction() +# --- what THIS TREE implements, answered by the build rather than by a person ---------- +# +# Two P2 entries assert something that only EXISTS once another P2 package has landed: +# HandleRecycleScenario's Handles arm needs a backend keyed on {slot, gen} (packages C and D), +# its AbaControl arm needs a consumer for MOBILEGL_PIPE_HANDLE_ABA_CONTROL (package D), and +# CsoContentAddressingScenario needs the client-side tracker that mints CSOs at all (package B). +# The gates package is written and merged FIRST, against the P2 contract commit, precisely so +# that the AbaControl red is on the record before either backend is touched - so for a while +# those entries have nothing to assert. +# +# The honest report for that is a SKIP naming what is missing, never a deleted registration and +# never a green that means "the thing I test does not exist yet". What decides the skip is +# THIS block, so that nobody has to remember to remove a hand-written guard: +# +# * two of the three answers are pure EXISTENCE checks, through file(GLOB CONFIGURE_DEPENDS). +# Ninja re-evaluates such a glob before every build and reconfigures only when the RESULT +# changes, so these cost nothing until the file appears - and then they arm themselves. +# * the third has to read a file's CONTENTS, because package D re-keys inside an existing +# source rather than adding one. VertexInputStateFactory.cpp is the one file both of D's +# answers live in (ComputeHash's buffer key is what the re-key changes AND what the ABA +# knob reverts), it is small, and it is watched by name - so an edit to it reconfigures and +# an edit anywhere else in the backend does not. +# +# Every verdict is printed at configure time: a marker that silently answered "no" for a tree +# that does implement the thing would turn a real gate into a permanent skip. +set(MGL_ITEST_CAPABILITY_ENV "") + +file(GLOB MGL_ITEST_ESPRYT_SLOT_TABLES CONFIGURE_DEPENDS + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectGLES/SlotTables.h") +if (MGL_ITEST_ESPRYT_SLOT_TABLES) + message(STATUS "Integration tests: DirectGLES is keyed on {slot, gen} (SlotTables.h present)") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectGLES=1") +else() + message(STATUS "Integration tests: DirectGLES has no SlotTables.h - HandleRecycle.Handles will SKIP on it") +endif() + +file(GLOB MGL_ITEST_TRACKER_SOURCE CONFIGURE_DEPENDS + "${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe/Tracker.cpp") +if (MGL_ITEST_TRACKER_SOURCE) + message(STATUS "Integration tests: the MGPipe tracker is present, so CSOs are minted") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_TRACKER_PRESENT=1") +else() + message(STATUS "Integration tests: no MG_Impl/Pipe/Tracker.cpp - CsoContentAddressing will SKIP") +endif() + +set(MGL_ITEST_MAGMA_VERTEX_INPUT + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp") +if (EXISTS "${MGL_ITEST_MAGMA_VERTEX_INPUT}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${MGL_ITEST_MAGMA_VERTEX_INPUT}") + file(STRINGS "${MGL_ITEST_MAGMA_VERTEX_INPUT}" MGL_ITEST_MAGMA_REKEY_HITS + REGEX "kMGPipeSubsystemMagmaVertexInput") + file(STRINGS "${MGL_ITEST_MAGMA_VERTEX_INPUT}" MGL_ITEST_MAGMA_ABA_HITS + REGEX "PipeHandleAbaControl") + if (MGL_ITEST_MAGMA_REKEY_HITS) + message(STATUS "Integration tests: DirectVulkan's vertex input is keyed on {slot, gen}") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectVulkan=1") + else() + message(STATUS "Integration tests: DirectVulkan's vertex input is not re-keyed yet - " + "HandleRecycle.Handles will SKIP on it") + endif() + if (MGL_ITEST_MAGMA_ABA_HITS) + message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has a consumer") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_ABA_IMPLEMENTED=1") + else() + message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has no consumer - " + "HandleRecycle.AbaControl will SKIP") + endif() +endif() + mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV}) mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT @@ -665,6 +736,162 @@ gtest_discover_tests(MobileGLIntegrationTest # why the arming case has a lane and a log of its own below, and why neither this file nor CI # may read the ambient logs as evidence about the entries that ran before the last one. The # ambient path is kept for post-mortems (and to keep library chatter out of ctest's capture). +# --- G8: the handle ABA, three always-on arms ----------------------------------------- +# +# ALWAYS ON, in every build mode, which is deliberate: the Legacy arm asserts today's +# lifetimeId + weak_ptr guards and is meaningful in a pull build, and `ctest -R HandleRecycle` +# has to name the same entries whichever build directory it is pointed at (P2 brief G8 runs it +# against build-verify; D.3 part 1 runs it again as part of the interface-purity gate). +# +# One lane per arm, and each lane names MGITEST_HANDLE_ARM: the arm is not a property of the +# test body, it is the (MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS, MOBILEGL_PIPE_HANDLE_ABA_CONTROL) +# triple the process was launched with, and the scenario skips in the ambient entries because +# none of that is configured there. +# +# Every list APPENDS the common/Vulkan environment for the reason spelled out above the verify +# block: a ctest ENVIRONMENT property REPLACES the job environment for the names it lists, so an +# entry naming only its own knobs would lose the EGL vendor and Vulkan ICD pinning. +# +# The AbaControl arm is DirectVulkan only. The knob reverts two DirectVulkan guards +# (VertexInputStateFactory::ComputeHash's key and LookupVaoDrawMemo's lifetimeId compare); it +# steers nothing on DirectGLES, and a lane that configured it there would be a permanent skip +# claiming to be a control. +mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=handles" "MOBILEGL_PIPE_LEGACY_MEMOS=0" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=handles" "MOBILEGL_PIPE_LEGACY_MEMOS=0" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) +mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=aba" "MOBILEGL_PIPE_PUSH=0" + "MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.HandleRecycle.Handles." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.HandleRecycle.Handles." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.HandleRecycle.Legacy." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.HandleRecycle.Legacy." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.HandleRecycle.AbaControl." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT}" +) + +# --- G12: the CSO content-addressing negative control --------------------------------- +# +# PUSH BUILDS ONLY, and that is the honest scope rather than a convenience: the two counters the +# control reads (CallClass::RenderStateCsoMints / RenderStateCsoBinds) and the `cso[...]` bracket +# of the summary line are both `#if MOBILEGL_PIPE_PUSH` (PipeStats.h, PipeStats.cpp), so in a pull +# build there is no channel to read and an entry here would be a permanent skip. +# +# Each arm gets a LOG PATH OF ITS OWN. The library opens its log fopen(path, "w") - every process +# in a lane truncates it - and these two cases READ that log, so a shared path would have them +# reading a neighbour's bring-up under `ctest -j 4`. Same rule as the arming lane below. +# +# MOBILEGL_PIPE_STATS_PERIOD=1 makes one summary line per eglSwapBuffers, which is what lets the +# workload be bracketed by two swaps and read back as a window covering exactly itself. +if (MOBILEGL_PIPE_PUSH) + mgl_itest_join_environment(MGL_ITEST_GLES_CSO_ON_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=content-addressed" + "MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=no-content-addressing" + "MOBILEGL_PIPE_PUSH=0x800000000000007f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=content-addressed" + "MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectVulkan.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=no-content-addressing" + "MOBILEGL_PIPE_PUSH=0x800000000000007f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectVulkan.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) + + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.CsoContentAddressing.On." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_CSO_ON_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.CsoContentAddressing.Off." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.CsoContentAddressing.On." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.CsoContentAddressing.Off." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT}" + ) +endif() + if (MOBILEGL_PIPE_VERIFY) # 900s, not the ambient 120: the comparator re-reads every field of the fill mask at the verb # boundary and again at every accessor read, which the design budgets at 5-10x. diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp new file mode 100644 index 00000000..53e2ea0b --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp @@ -0,0 +1,321 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.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 - THE CSO CONTENT-ADDRESSING NEGATIVE CONTROL (gate G12). +// +// P2's render-state CSO is content-addressed: the client hashes the 396 pipeline bytes, probes a +// 64-entry cache, memcmps a hash hit and reuses the handle. The whole design is measured against +// a knob that turns that off - kMGPipeBehaviourNoCsoContentAddressing, bit 63 of the runtime +// MOBILEGL_PIPE_PUSH bitmask - so that "push is slower" can be told apart from "the CSO design is +// slower" (P2 brief D.4.5). A measurement knob has one characteristic failure mode: it stops +// steering anything and every later number is quietly taken against a switch that does nothing. +// This file is the entry that cannot let that happen. +// +// WHAT IT ASSERTS, per arm, and why those are the right shapes: +// +// content-addressed (MOBILEGL_PIPE_PUSH=0x7f) +// A Blaze3D blend toggle - enable / draw / disable / draw, N times, which is the workload +// the CsoCache exists for (ARCHITECTURE.md 5.1: the push happens at validate rather than in +// the setter precisely because Blaze3D brackets every batch this way) - visits exactly TWO +// distinct pipeline subsets. So the mint count must stay small and BOUNDED while the bind +// count grows with the draws: csom << csob. +// +// no content addressing (MOBILEGL_PIPE_PUSH=0x800000000000007f) +// Every pipeline-version change mints a fresh CSO and the map is never probed, so mint and +// bind must move together: csom == csob. This is the assertion a dead switch fails - with +// the bit ignored, this arm would report csom << csob just like the other one. +// +// both arms +// The PIXELS must not move. The quad is drawn with alpha 1.0 through +// GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA, so the blended and unblended draws produce the +// same colour by construction and the readback is the same image in both arms and after +// every toggle. "The counters moved and the picture did not" is the whole claim. +// +// HOW THE COUNTERS ARE READ. MG_Util::PipeStats is internal to the library and this module cannot +// link against it (ScenarioFixture.h explains why: on Android this binary links the SHIPPING +// libMobileGL.so, built -fvisibility=hidden). The library's own summary line is the only channel, +// so each lane sets MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 - one line per +// eglSwapBuffers - and a MOBILEGL_LOG_FILE_PATH of its OWN. The log path has to be private: the +// library opens it fopen(path, "w"), so every process in a lane truncates it, and a whole-file +// read in a shared lane races a neighbour's bring-up. That is the same rule, and the same +// remedy, as PipeVerifyArmingScenario's arming lane. +// +// The window a summary line reports is "since the previous line" (PipeStats::FormatWindowLine), so +// the workload runs inside ONE frame: a swap before it closes the setup window, and the swap after +// it emits a line whose csom / csob cover the toggle loop and nothing else. +// +// WHY IT CAN SKIP. The counters are minted by the client-side tracker (P2 package B), and this +// file is written against the P2 contract commit, before that package lands. Until the tracker +// exists there is no CSO to mint, csom is structurally 0 and an assertion about its ratio to csob +// would be a statement about nothing. The build answers the question rather than a hand-maintained +// list: MG_IntegrationTest/CMakeLists.txt looks for MG_Impl/Pipe/Tracker.cpp and passes the answer +// in as MGITEST_PIPE_TRACKER_PRESENT, with a CONFIGURE_DEPENDS on that directory so the answer +// cannot go stale. When the tracker lands the arms arm themselves; until then the entries are +// registered, visible and SKIPPED with the reason - never absent, and never green for having +// asserted nothing. + +#include +#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 + +namespace MGITest { + namespace { + + // Set by the two CsoContentAddressing. ctest entries and by nothing else; a harness + // marker, never read by the library. Its absence means an ambient entry, where neither + // the stats channel nor a private log path is configured. + constexpr const char* kLaneMarker = "MGITEST_CSO_LANE"; + constexpr const char* kLaneContentAddressed = "content-addressed"; + constexpr const char* kLaneNoContentAddressing = "no-content-addressing"; + + // Toggle pairs per frame. 8 is small enough to keep the frame cheap and large enough that + // "mints stay bounded" and "mints track binds" are different numbers by a wide margin. + constexpr int kTogglePairs = 8; + constexpr int kDrawsPerFrame = kTogglePairs * 2; + // The blend toggle visits two distinct pipeline subsets, so two CSOs. The bound is + // deliberately a little looser than 2: a future chunk-table change could legitimately + // split one of them, and the claim being pinned here is "bounded, not per-draw". + constexpr long long kMaxDistinctCsos = 4; + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +)"; + + constexpr const char* kFS = R"(#version 330 core +out vec4 oColor; +void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + constexpr int kInset = 2; + + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + + std::string LaneName() { + const char* lane = std::getenv(kLaneMarker); + return lane != nullptr ? std::string(lane) : std::string(); + } + + std::string LibraryLogPath() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::string(path) : std::string(); + } + + std::string ReadWholeFile(const std::string& path) { + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + } + + // One window's CSO counters, as the library printed them. + struct CsoWindow { + bool found = false; + long long mints = -1; + long long binds = -1; + std::string line; + }; + + // Parses `... cso[csom= csob=] ...` out of the LAST "MGPipe stats:" line in the log. + // The last line, because the window a line reports is "since the previous line" and the + // caller closes the setup window with a swap before the workload. + CsoWindow LastCsoWindow(const std::string& log) { + CsoWindow window; + const std::string marker = "MGPipe stats:"; + std::size_t at = log.rfind(marker); + if (at == std::string::npos) return window; + const std::size_t end = log.find('\n', at); + window.line = log.substr(at, end == std::string::npos ? std::string::npos : end - at); + + const std::string mintKey = "csom="; + const std::string bindKey = "csob="; + const std::size_t mintAt = window.line.find(mintKey); + const std::size_t bindAt = window.line.find(bindKey); + if (mintAt == std::string::npos || bindAt == std::string::npos) return window; + window.mints = std::strtoll(window.line.c_str() + mintAt + mintKey.size(), nullptr, 10); + window.binds = std::strtoll(window.line.c_str() + bindAt + bindKey.size(), nullptr, 10); + window.found = true; + return window; + } + + class CsoContentAddressingScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_lane = LaneName(); + std::string error; + m_program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(m_program, 0u) << error; + + const float quad[12] = {-1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, + -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f}; + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glGenBuffers(1, &m_vbo); + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind"; + RecordProperty("lane", m_lane.empty() ? "ambient" : m_lane.c_str()); + } + + void TearDown() override { + if (!Ready()) return; + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + if (m_vbo != 0) glDeleteBuffers(1, &m_vbo); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_program != 0) glDeleteProgram(m_program); + } + + // GTEST_SKIP() returns from the function it is written in, so this cannot report + // through a return value; every caller pairs it with `if (IsSkipped()) return;`. + void SkipUnlessTheLaneIsAssertableHere() { + if (m_lane.empty()) { + GTEST_SKIP() << "runs only in its own lane: the two CsoContentAddressing. ctest entries set " + "MGITEST_CSO_LANE together with the MOBILEGL_PIPE_PUSH bitmask, " + "MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private " + "MOBILEGL_LOG_FILE_PATH. None of that is configured in the ambient " + "entries, and the ambient log is shared, so a read here would race."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_TRACKER_PRESENT")) { + GTEST_SKIP() << "the CSO counters have no emitter in this build: MG_Impl/Pipe/Tracker.cpp " + "does not exist, so nothing mints or binds a render-state CSO and " + "csom / csob are structurally zero. P2 package B owns the tracker; this " + "entry arms itself when it lands."; + return; + } + if (LibraryLogPath().empty()) { + GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's summary " + "line is the only channel this module has for reading PipeStats"; + return; + } + } + + // enable / draw / disable / draw, kTogglePairs times, entirely inside one frame. + // Returns the readback taken at the end of that frame, before the swap. + Image RunBlendToggleFrame() { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_program); + glBindVertexArray(m_vao); + glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO); + for (int i = 0; i < kTogglePairs; ++i) { + glEnable(GL_BLEND); + glDrawArrays(GL_TRIANGLES, 0, 6); + glDisable(GL_BLEND); + glDrawArrays(GL_TRIANGLES, 0, 6); + } + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + } + + std::string m_lane; + GLuint m_program = 0; + GLuint m_vao = 0; + GLuint m_vbo = 0; + }; + + // The plumbing, asserted on its own so that a counter-ratio failure below can never be + // confused with "the lane never turned the stats channel on". + TEST_F(CsoContentAddressingScenario, TheLibrarysSummaryLineCarriesTheCsoCounters) { + if (!Ready()) return; + SkipUnlessTheLaneIsAssertableHere(); + if (IsSkipped()) return; + + Gl().EndFrame(); // close the setup window + RunBlendToggleFrame(); + + const CsoWindow window = LastCsoWindow(ReadWholeFile(LibraryLogPath())); + ASSERT_TRUE(window.found) + << "no 'MGPipe stats:' line carrying cso[csom= csob=] in " << LibraryLogPath() + << ". Either MOBILEGL_PIPE_STATS/MOBILEGL_PIPE_STATS_PERIOD did not reach the process, or " + "this library was not built with MOBILEGL_PIPE_PUSH - the two counters and the cso[] " + "bracket are both #if MOBILEGL_PIPE_PUSH (PipeStats.h, PipeStats.cpp FormatWindowLine)."; + EXPECT_GE(window.binds, 0) << window.line; + EXPECT_GE(window.mints, 0) << window.line; + RecordProperty("cso_line", window.line.c_str()); + } + + // The control itself. + TEST_F(CsoContentAddressingScenario, TheBlendToggleMintsBoundedlyWithContentAddressingAndPerBindWithout) { + if (!Ready()) return; + SkipUnlessTheLaneIsAssertableHere(); + if (IsSkipped()) return; + + Gl().EndFrame(); // close the setup window + const Image first = RunBlendToggleFrame(); + const CsoWindow window = LastCsoWindow(ReadWholeFile(LibraryLogPath())); + ASSERT_TRUE(window.found) << "no CSO counters in " << LibraryLogPath() + << " - see TheLibrarysSummaryLineCarriesTheCsoCounters"; + RecordProperty("cso_line", window.line.c_str()); + + // Every draw in the frame changed the pipeline subset, so every draw is a bind. This + // is the denominator both arms are read against; without it, "csom == csob" would also + // be satisfied by a frame in which neither happened at all. + ASSERT_GE(window.binds, static_cast(kDrawsPerFrame)) + << "the toggle frame issued " << kDrawsPerFrame + << " draws whose pipeline subset alternates, so it must have issued at least that many " + "render-state binds. It reported: " + << window.line; + + if (m_lane == kLaneContentAddressed) { + EXPECT_LE(window.mints, kMaxDistinctCsos) + << "with content addressing on, enable/draw/disable/draw x " << kTogglePairs + << " visits two distinct pipeline subsets and must mint a bounded number of CSOs, then " + "reuse them. It reported: " + << window.line; + EXPECT_LT(window.mints, window.binds) + << "with content addressing on the cache must be answering binds it did not mint. " + << window.line; + } else if (m_lane == kLaneNoContentAddressing) { + EXPECT_EQ(window.mints, window.binds) + << "kMGPipeBehaviourNoCsoContentAddressing (bit 63 of MOBILEGL_PIPE_PUSH) must make every " + "bind mint a fresh CSO - the map is never probed and no handle is ever reused. Equal " + "counters are the only reading that proves the bit STEERED anything: if it were " + "ignored, this arm would report the same bounded mint count as the other one. It " + "reported: " + << window.line; + } else { + FAIL() << "unknown " << kLaneMarker << " value '" << m_lane << "'"; + } + + // ... and the picture is the same in both arms and after every toggle. The quad is + // opaque, so the blended and unblended draws agree by construction. + EXPECT_TRUE(RegionIsMostly(first, kInset, first.Width() - kInset, kInset, first.Height() - kInset, + "green", 0.0, "the blend-toggle frame [" + m_lane + "]")); + const Image second = RunBlendToggleFrame(); + EXPECT_TRUE(second == first) + << "the second toggle frame does not match the first: " << second.ByteDiffCount(first) + << " bytes differ. The CSO path must not change what is drawn."; + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp new file mode 100644 index 00000000..79268293 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp @@ -0,0 +1,582 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.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 - THE HANDLE ABA (gate G8): a frontend object that dies and is replaced at the same +// heap address must not inherit the dead object's backend twin, its vertex-input state, or its +// draw memo. +// +// WHY THIS EXISTS. Every backend memo in the tree is keyed, today, on some property of a LIVE +// frontend object: a raw `void*` owner pointer (DirectGLES' StateBackendObjectRegistry and its +// three TwinLookupMemos), a `GetLifetimeId()` (DirectVulkan's VertexInputStateFactory::ComputeHash +// and VaoDrawMemo::vaoLifetimeId), or a weak_ptr expiry test. Track H replaces all of them with an +// {slot, gen} handle. The question this scenario asks is the only one that matters about that +// change: does the NEW key actually stop the aliasing the OLD key stopped? A re-key that quietly +// dropped a guard would produce pixels from a dead object's GPU resources, and there is no other +// gate in this tree that can see it - SSIM over a 40-trace corpus cannot, because no fixture +// destroys and immediately re-creates an object with a byte-identical configuration. +// +// HOW THE ABA IS BUILT, through public GL only: +// 1. an object is created, USED IN A DRAW, and used again for a few frames, so that every +// per-object memo in both backends is armed against it; +// 2. it is unbound (so the frontend's last SharedPtr drops - a still-bound object keeps living, +// TextureState.cpp) and deleted; +// 3. a replacement is created IMMEDIATELY, with a byte-identical configuration, so that a +// content hash over the configuration matches the dead object's, and so that the allocator +// is as likely as it can be made to hand back the address it just freed; +// 4. the replacement is given DIFFERENT CONTENTS - a different vertex buffer, different texels, +// a different attachment; +// 5. one draw, one readback. The pixels must come from the replacement. +// +// The allocator is not under our control, so step 3 is a likelihood, not a guarantee, and a +// scenario that silently passed because the address was never reused would prove nothing. The +// public-GL proxy for "the allocator repeated itself" is the GL NAME: MobileGL's name allocators +// hand a deleted name straight back, so `TheReproducerRecyclesEveryName` asserts the recycle +// happened and every other case asserts on the name it got. When a name is NOT recycled the case +// SKIPS with that reason rather than passing - the shape MG_Test/State/ObjectLifetimeIdTest.cpp +// already uses for exactly this ("inconclusive, not proven"). +// +// THREE ARMS, ALL ALWAYS ON (P2 brief D18). The arm is named by MGITEST_HANDLE_ARM, which is a +// HARNESS marker - the library never reads it - and the CMake wiring registers one lane per arm: +// +// Handles MOBILEGL_PIPE_PUSH default (Track H bits set), MOBILEGL_PIPE_LEGACY_MEMOS=0. +// The {slot, gen} key is the only key in the process. Expects correct pixels. +// Legacy MOBILEGL_PIPE_PUSH=0. Today's lifetimeId + weak_ptr guards. Expects correct +// pixels - they work, which is the point: the re-key is not fixing a live bug, it +// is replacing a guard, and the replacement has to be at least as strong. +// AbaControl MOBILEGL_PIPE_PUSH=0 AND MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1. The knob reverts +// exactly the two guards the re-key replaces (VertexInputStateFactory::ComputeHash +// hashes attr.Buffer.get() instead of GetLifetimeId(); LookupVaoDrawMemo skips the +// vaoLifetimeId compare), so this arm expects the CORRUPTION. It is what makes +// `HandleRecycleScenario green, and red before the re-key` an always-on CI fact +// instead of a one-off manual demonstration: if the reproducer ever stops +// reproducing the ABA, this arm fails. +// +// WHY AN ARM CAN SKIP, AND WHY THAT IS NOT A HOLE. Two of the three arms assert something that +// only EXISTS once another P2 package has landed: `Handles` needs the backend's {slot, gen} arm +// (packages C and D) and `AbaControl` needs the knob's consumer (package D). This file is written +// and merged FIRST, against the P2 contract commit, so that the AbaControl red is recorded before +// either backend is touched. Until then those arms have nothing to assert, and the honest report +// for that is a SKIP that names what is missing - never a silently-deleted registration and never +// a green that means "the thing I test does not exist yet". +// +// The skip is decided by the BUILD, not by a hand-maintained list: MG_IntegrationTest/CMakeLists.txt +// greps the backend sources for the subsystem constant and for the knob's name and passes the +// answer in as MGITEST_HANDLE_REKEY_ / MGITEST_HANDLE_ABA_IMPLEMENTED, with a +// CMAKE_CONFIGURE_DEPENDS on those files so the answer cannot go stale. When C and D land, the +// arms arm themselves. + +#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 + +namespace MGITest { + namespace { + + // ---- the arm ------------------------------------------------------------------- + + enum class Arm { + Handles, // the {slot, gen} key is the only key + Legacy, // today's lifetimeId + weak_ptr guards + AbaControl // the guards deliberately defeated; the corruption is the assertion + }; + + // Set by the three HandleRecycle. ctest entries and by NOTHING else. It is a harness + // variable, not a library knob (hence the MGITEST_ prefix): the library never reads it. + // Its absence means "this process is one of the ~400 ambient entries", where the arm is + // undefined - MOBILEGL_PIPE_PUSH is at its build default there, which is neither the + // Legacy arm nor the Handles arm - so the cases skip rather than assert something the + // lane did not configure. Same shape, and the same reason, as + // PipeVerifyArmingScenario's MGITEST_PIPE_ARMING_LANE. + constexpr const char* kArmMarker = "MGITEST_HANDLE_ARM"; + + Arm CurrentArm() { + const char* name = std::getenv(kArmMarker); + if (name == nullptr) return Arm::Legacy; + if (std::strcmp(name, "handles") == 0) return Arm::Handles; + if (std::strcmp(name, "aba") == 0) return Arm::AbaControl; + return Arm::Legacy; + } + + bool RunningInAHandleRecycleLane() { return std::getenv(kArmMarker) != nullptr; } + + const char* ArmName(Arm arm) { + switch (arm) { + case Arm::Handles: return "Handles"; + case Arm::AbaControl: return "AbaControl"; + default: return "Legacy"; + } + } + + // A build-time marker set by MG_IntegrationTest/CMakeLists.txt. "1" means the thing it + // names is present in the sources this binary was built from. + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + + // Whichever of the two backend re-keys applies to the process this binary is running as. + bool ThisBackendsRekeyHasLanded() { + const std::string& backend = HeadlessGL::Get().BackendName(); + if (backend == "DirectVulkan") return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_DirectVulkan"); + return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_DirectGLES"); + } + + // ---- the scene ----------------------------------------------------------------- + + constexpr const char* kColorVS = R"(#version 330 core +in vec2 aPos; +in vec3 aColor; +out vec3 vColor; +void main() { + vColor = aColor; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kColorFS = R"(#version 330 core +in vec3 vColor; +out vec4 oColor; +void main() { oColor = vec4(vColor, 1.0); } +)"; + + constexpr const char* kSampleVS = R"(#version 330 core +in vec2 aPos; +out vec2 vUv; +void main() { + vUv = aPos * 0.5 + 0.5; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kSampleFS = R"(#version 330 core +in vec2 vUv; +uniform sampler2D uTex; +out vec4 oColor; +void main() { oColor = texture(uTex, vUv); } +)"; + + struct Vertex { + float x, y; + float r, g, b; + }; + + // A full-viewport quad in one colour. Both buffers are the SAME SIZE and the SAME + // LAYOUT: only the colour bytes differ, which is what makes a content hash over the + // vertex-input CONFIGURATION identical between them. + std::vector Quad(float r, float g, float b) { + return { + {-1.0f, -1.0f, r, g, b}, {1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b}, + {-1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b}, {-1.0f, 1.0f, r, g, b}, + }; + } + + constexpr int kVertexCount = 6; + // Enough consecutive drawing frames that every per-object memo in both backends is armed + // against the first object before it is destroyed. + constexpr int kWarmupFrames = 3; + // How far inside the viewport the whole-region check starts. The quad covers everything, + // so the inset is only about primitive edges on the outermost pixel row/column. + constexpr int kInset = 2; + + void ExpectWholeViewportIs(const Image& image, const char* expected, const std::string& when) { + EXPECT_TRUE(RegionIsMostly(image, kInset, image.Width() - kInset, kInset, image.Height() - kInset, + expected, 0.0, when)); + } + + // The one thing the whole file turns on: did the pixels come from the REPLACEMENT + // (`fresh`) or from the object that died (`stale`)? The arm decides which is the pass. + void ExpectPixelsFor(Arm arm, bool armExpectsCorruption, const Image& image, const char* fresh, + const char* stale, const std::string& when) { + if (arm == Arm::AbaControl && armExpectsCorruption) { + // The corruption IS the assertion. If this ever goes green-by-being-correct the + // reproducer has stopped reproducing and the other two arms prove nothing. + ExpectWholeViewportIs(image, stale, when + " [AbaControl expects the STALE object's pixels: " + "the two guards are deliberately defeated]"); + return; + } + ExpectWholeViewportIs(image, fresh, + when + " [" + ArmName(arm) + " expects the replacement's pixels]"); + } + + class HandleRecycleScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_arm = CurrentArm(); + std::string error; + m_colorProgram = CompileProgram(kColorVS, kColorFS, &error); + ASSERT_NE(m_colorProgram, 0u) << error; + m_sampleProgram = CompileProgram(kSampleVS, kSampleFS, &error); + ASSERT_NE(m_sampleProgram, 0u) << error; + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "program setup left a GL error behind"; + RecordProperty("arm", ArmName(m_arm)); + } + + void TearDown() override { + if (!Ready()) return; + if (m_colorProgram != 0) glDeleteProgram(m_colorProgram); + if (m_sampleProgram != 0) glDeleteProgram(m_sampleProgram); + } + + // Skips the case when the arm it is running under has nothing to assert on THIS tree. + // GTEST_SKIP() returns from the function it is written in, so this cannot report + // through a return value; every caller pairs it with `if (IsSkipped()) return;`. + void SkipUnlessTheArmIsAssertableHere() { + if (!RunningInAHandleRecycleLane()) { + GTEST_SKIP() << "runs only in its own lane: the three HandleRecycle. ctest entries set " + "MGITEST_HANDLE_ARM (handles / legacy / aba) together with the " + "MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_LEGACY_MEMOS values that arm means. " + "The ambient entries configure none of that, so there is nothing here to " + "assert."; + } + switch (m_arm) { + case Arm::Handles: + if (!ThisBackendsRekeyHasLanded()) { + GTEST_SKIP() << "the Handles arm needs the backend's {slot, gen} re-key, and this " + "build does not have it: no source under MobileGL/MG_Backend/" + << Gl().BackendName() + << " mentions the Track H subsystem constant (P2 package C for " + "DirectGLES, package D for DirectVulkan). The arm is registered " + "and visible, and arms itself when that package lands."; + } + return; + case Arm::AbaControl: + if (!BuildMarkerIsSet("MGITEST_HANDLE_ABA_IMPLEMENTED")) { + GTEST_SKIP() << "the AbaControl arm needs MOBILEGL_PIPE_HANDLE_ABA_CONTROL to have a " + "consumer, and this build has none: MG_Config parses the knob " + "(ConfigLoader.cpp) but no source under MobileGL/MG_Backend/ reads " + "Features.PipeHandleAbaControl, so the two guards the knob is " + "supposed to defeat are still in force and the ABA cannot be " + "reproduced. P2 package D owns that consumer."; + } + return; + default: return; + } + } + + // A VBO holding one solid-colour quad. + GLuint MakeQuadBuffer(float r, float g, float b) { + const std::vector vertices = Quad(r, g, b); + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glBufferData(GL_ARRAY_BUFFER, + static_cast(vertices.size() * sizeof(Vertex)), vertices.data(), + GL_STATIC_DRAW); + return buffer; + } + + // The attribute configuration, spelled once so the two VAOs are byte-identical. + void ConfigureQuadVao(GLuint vao, GLuint buffer) { + glBindVertexArray(vao); + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(offsetof(Vertex, x))); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(offsetof(Vertex, r))); + } + + Image DrawQuadAndRead(GLuint vao) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_colorProgram); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + } + + // A 2x2 RGBA8 texture of one colour, with the sampling parameters spelled the same + // way both times so a parameter-shadow key matches too. + GLuint MakeSolidTexture(std::uint8_t r, std::uint8_t g, std::uint8_t b) { + const std::uint8_t texels[16] = {r, g, b, 255, r, g, b, 255, r, g, b, 255, r, g, b, 255}; + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); + return texture; + } + + Image DrawTexturedQuadAndRead(GLuint vao, GLuint texture) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_sampleProgram); + glUniform1i(glGetUniformLocation(m_sampleProgram, "uTex"), 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + } + + Arm m_arm = Arm::Legacy; + GLuint m_colorProgram = 0; + GLuint m_sampleProgram = 0; + }; + + // ------------------------------------------------------------------------------------ + // The self-check. Without it the three cases below could all be green because the name + // allocator never repeated itself, i.e. because the ABA never happened. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, TheReproducerRecyclesEveryName) { + if (!Ready()) return; + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint vaoAgain = 0; + glGenVertexArrays(1, &vaoAgain); + EXPECT_EQ(vao, vaoAgain) << "glGenVertexArrays did not hand the deleted name back, so the " + "vertex-array case below cannot be constructing an ABA"; + glDeleteVertexArrays(1, &vaoAgain); + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glBindTexture(GL_TEXTURE_2D, 0); + glDeleteTextures(1, &texture); + GLuint textureAgain = 0; + glGenTextures(1, &textureAgain); + EXPECT_EQ(texture, textureAgain) << "glGenTextures did not hand the deleted name back"; + glDeleteTextures(1, &textureAgain); + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &fbo); + GLuint fboAgain = 0; + glGenFramebuffers(1, &fboAgain); + EXPECT_EQ(fbo, fboAgain) << "glGenFramebuffers did not hand the deleted name back"; + glDeleteFramebuffers(1, &fboAgain); + + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + } + + // ------------------------------------------------------------------------------------ + // 1. The vertex array. This is the case the AbaControl knob targets: DirectVulkan keys + // VertexInputStateFactory's cache on the attribute's buffer identity and VaoDrawMemo + // on the VAO's, and BOTH the VAO and the buffer are recycled here so that a key built + // out of raw addresses matches while the bytes behind it do not. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, AVertexArrayAtARecycledAddressDoesNotInheritItsPredecessorsVertexInput) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + const GLuint redBuffer = MakeQuadBuffer(1.0f, 0.0f, 0.0f); + GLuint redVao = 0; + glGenVertexArrays(1, &redVao); + ConfigureQuadVao(redVao, redBuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first VAO left a GL error behind"; + + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = DrawQuadAndRead(redVao); + ExpectWholeViewportIs(warm, "red", "warm-up frame " + std::to_string(frame)); + } + + // Unbind FIRST: a still-bound object keeps living, so the last SharedPtr would not + // drop and there would be no freed block for the replacement to land in. + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteVertexArrays(1, &redVao); + GLuint doomedBuffer = redBuffer; + glDeleteBuffers(1, &doomedBuffer); + + // The replacement, immediately and in the reverse order of the frees, which is the + // order a size-classed allocator is most likely to answer from its free lists. + const GLuint greenBuffer = MakeQuadBuffer(0.0f, 1.0f, 0.0f); + GLuint greenVao = 0; + glGenVertexArrays(1, &greenVao); + ConfigureQuadVao(greenVao, greenBuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the replacement VAO left a GL error behind"; + + if (greenVao != redVao || greenBuffer != redBuffer) { + GTEST_SKIP() << "inconclusive, not proven: the name allocator did not hand both names back " + "(vao " << redVao << " -> " << greenVao << ", buffer " << redBuffer << " -> " + << greenBuffer << "), so no ABA was constructed"; + } + RecordProperty("recycled_vao_name", static_cast(greenVao)); + + const Image image = DrawQuadAndRead(greenVao); + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/true, image, "green", "red", + "the draw after the VAO and its buffer were both recycled"); + + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteVertexArrays(1, &greenVao); + GLuint cleanup = greenBuffer; + glDeleteBuffers(1, &cleanup); + } + + // ------------------------------------------------------------------------------------ + // 2. The texture. DirectGLES keeps a backend twin per frontend texture in a registry + // keyed on the frontend object's address (StateBackendObjectRegistry + the + // UnitSamplerLookupMemo's weak_ptr test); a replacement at the same address must not + // sample the dead texture's driver object. + // + // The AbaControl knob does not steer this path, so this case expects the correct + // pixels in EVERY arm - stated explicitly rather than by omission. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, ATextureAtARecycledAddressDoesNotInheritItsPredecessorsTwin) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + + const GLuint redTexture = MakeSolidTexture(255, 0, 0); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first texture left a GL error behind"; + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = DrawTexturedQuadAndRead(vao, redTexture); + ExpectWholeViewportIs(warm, "red", "warm-up frame " + std::to_string(frame)); + } + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, 0); + GLuint doomed = redTexture; + glDeleteTextures(1, &doomed); + + const GLuint greenTexture = MakeSolidTexture(0, 255, 0); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the replacement texture left a GL error"; + if (greenTexture != redTexture) { + GTEST_SKIP() << "inconclusive, not proven: glGenTextures returned " << greenTexture + << " rather than the deleted " << redTexture << ", so no ABA was constructed"; + } + RecordProperty("recycled_texture_name", static_cast(greenTexture)); + + const Image image = DrawTexturedQuadAndRead(vao, greenTexture); + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/false, image, "green", "red", + "the draw after the texture was recycled"); + + glBindTexture(GL_TEXTURE_2D, 0); + GLuint cleanupTexture = greenTexture; + glDeleteTextures(1, &cleanupTexture); + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + } + + // ------------------------------------------------------------------------------------ + // 3. The framebuffer. The readback is deliberately NOT from the framebuffer under test: + // a clear that landed in the WRONG framebuffer would still read back green through + // that framebuffer. It is taken from the replacement's own attachment with + // glGetTexImage, so "the clear went somewhere else" is visible as a texture that + // never became green. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, AFramebufferAtARecycledAddressDoesNotInheritItsPredecessorsTwin) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + // Two attachments that stay alive for the whole case, so the only recycled object is + // the framebuffer itself. + GLuint firstAttachment = 0; + GLuint secondAttachment = 0; + glGenTextures(1, &firstAttachment); + glBindTexture(GL_TEXTURE_2D, firstAttachment); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glGenTextures(1, &secondAttachment); + glBindTexture(GL_TEXTURE_2D, secondAttachment); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glBindTexture(GL_TEXTURE_2D, 0); + + GLuint firstFbo = 0; + glGenFramebuffers(1, &firstFbo); + glBindFramebuffer(GL_FRAMEBUFFER, firstFbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, firstAttachment, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + for (int frame = 0; frame < kWarmupFrames; ++frame) { + glBindFramebuffer(GL_FRAMEBUFFER, firstFbo); + glViewport(0, 0, 4, 4); + ClearTo(1.0f, 0.0f, 0.0f, 1.0f); + BindDefaultFramebuffer(); + Gl().EndFrame(); + } + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &firstFbo); + + GLuint secondFbo = 0; + glGenFramebuffers(1, &secondFbo); + if (secondFbo != firstFbo) { + glDeleteFramebuffers(1, &secondFbo); + GLuint cleanup[2] = {firstAttachment, secondAttachment}; + glDeleteTextures(2, cleanup); + GTEST_SKIP() << "inconclusive, not proven: glGenFramebuffers returned " << secondFbo + << " rather than the deleted " << firstFbo << ", so no ABA was constructed"; + } + RecordProperty("recycled_framebuffer_name", static_cast(secondFbo)); + + glBindFramebuffer(GL_FRAMEBUFFER, secondFbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, secondAttachment, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + glViewport(0, 0, 4, 4); + ClearTo(0.0f, 1.0f, 0.0f, 1.0f); + BindDefaultFramebuffer(); + Gl().EndFrame(); + + // Read the REPLACEMENT'S attachment, not the framebuffer: that is what makes "the + // clear landed in the dead framebuffer" visible. + std::vector texels(4 * 4 * 4, 0); + glBindTexture(GL_TEXTURE_2D, secondAttachment); + glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels.data()); + glBindTexture(GL_TEXTURE_2D, 0); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + // Every texel of the replacement's attachment must be the green it was cleared to. + int offenders = 0; + for (std::size_t i = 0; i < texels.size(); i += 4) { + if (texels[i] != 0 || texels[i + 1] != 255 || texels[i + 2] != 0) ++offenders; + } + EXPECT_EQ(offenders, 0) << "the replacement framebuffer's own attachment is not the colour it was " + "cleared to, so the clear reached a framebuffer this one only shares an " + "address with (first texel rgba=" + << static_cast(texels[0]) << "," << static_cast(texels[1]) << "," + << static_cast(texels[2]) << "," << static_cast(texels[3]) << ")"; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &secondFbo); + GLuint cleanup[2] = {firstAttachment, secondAttachment}; + glDeleteTextures(2, cleanup); + } + + } // namespace +} // namespace MGITest