[Test] (Pipe): reproduce the handle ABA through public GL and pin the CSO content-addressing switch

- HandleRecycleScenario builds the ABA the Track H re-key has to survive: an object is drawn
  for three frames so every per-object memo is armed against it, unbound so its last SharedPtr
  drops, deleted, and replaced immediately by one with a byte-identical configuration and
  different contents. Three kinds - a vertex array whose buffer is recycled with it, a texture,
  a framebuffer - and the readback must come from the replacement.
- The reproducer is asserted, not assumed. TheReproducerRecyclesEveryName pins that the name
  allocators hand every deleted name straight back, and a case whose names were not recycled
  SKIPS as "inconclusive, not proven" rather than passing - the shape ObjectLifetimeIdTest
  already uses.
- Three always-on arms, one ctest lane each, named by the harness marker MGITEST_HANDLE_ARM:
  Handles ({slot, gen} only), Legacy (today's lifetimeId + weak_ptr guards) and AbaControl
  (MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1, which expects the CORRUPTION so that a reproducer that
  stopped reproducing is a red rather than a quieter green). AbaControl is DirectVulkan only:
  the knob reverts two DirectVulkan guards and steers nothing on DirectGLES.
- CsoContentAddressingScenario is the G12 control. A Blaze3D blend toggle - enable/draw/
  disable/draw x 8 inside one frame - must mint a BOUNDED number of CSOs with content
  addressing on and exactly one per bind with bit 63 of MOBILEGL_PIPE_PUSH set, while the
  pixels do not move at all. csom == csob is the reading a dead switch cannot produce.
- The counters are read from the library's own "MGPipe stats:" line, because PipeStats is
  internal and this module links the shipping library on Android. Each arm therefore gets
  MOBILEGL_PIPE_STATS_PERIOD=1 and a private MOBILEGL_LOG_FILE_PATH, the same per-lane rule
  the arming lane already follows, and the workload is bracketed by two swaps so the window
  covers itself and nothing else.
- Both scenarios skip in the ambient entries, which configure none of the knobs their arms are
  about, and both name what is missing when the package they depend on has not landed. What
  decides that is the BUILD, not a hand-written guard: CMakeLists looks for
  DirectGLES/SlotTables.h, MG_Impl/Pipe/Tracker.cpp and the two markers inside
  VertexInputStateFactory.cpp, prints each verdict, and re-evaluates through CONFIGURE_DEPENDS -
  so the arms arm themselves when packages B, C and D land.
This commit is contained in:
2026-09-07 23:18:09 -04:00
parent e5c032c89e
commit f1780b9000
3 changed files with 1130 additions and 0 deletions
+227
View File
@@ -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.
@@ -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 <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#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<char>(file)), std::istreambuf_iterator<char>());
}
// 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=<N> csob=<M>] ...` 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<long long>(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
@@ -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_<backend> / 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 <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#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<Vertex> 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<Vertex> vertices = Quad(r, g, b);
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER,
static_cast<GLsizeiptr>(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<const void*>(offsetof(Vertex, x)));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<const void*>(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<int>(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<int>(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<int>(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<std::uint8_t> 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<int>(texels[0]) << "," << static_cast<int>(texels[1]) << ","
<< static_cast<int>(texels[2]) << "," << static_cast<int>(texels[3]) << ")";
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &secondFbo);
GLuint cleanup[2] = {firstAttachment, secondAttachment};
glDeleteTextures(2, cleanup);
}
} // namespace
} // namespace MGITest