Files
MobileGL/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp
T

1118 lines
65 KiB
C++

// 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, its
// buffer contents, or its draw memo.
//
// P3a ADDS TWO WINDOWS to the four P2 wrote, because it re-keys two more object classes. The
// BUFFER (ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents) is the resource_*
// family's: a store that dies and is replaced at the same slot must not hand the replacement's
// draw the dead store's bytes. The two-buffer VERTEX SET
// (AVertexArrayAtARecycledAddressDoesNotInheritItsPredecessorsVertexBufferSet) is
// set_vertex_buffers': the per-binding buffer identities are a record of their own, separate
// from the elements blob, and a recycled VAO must not inherit its predecessor's.
//
// 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;
// 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 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").
//
// WHAT THE NAME PROXY DOES NOT BUY, MEASURED RATHER THAN ASSUMED. The name comes back; the C++
// HEAP BLOCK does not. A VertexArrayObject is 3920 bytes - past glibc's tcache - so its chunk goes
// to the unsorted bin and is split by the very next allocation the replacement path makes; four
// create/delete cycles in one run of this file produced four distinct addresses about a mebibyte
// apart, and the same is true of the BufferObject. An earlier revision of this file left the
// AbaControl arm's collision to that allocator, and the consequence was the failure mode this file
// exists to prevent, in its most literal form: with nothing colliding, the replacement inherited
// nothing, the arm asserted stale pixels, saw fresh ones, and went RED in an always-on
// integration-gpu lane while every guard it was supposed to be defeating was still standing.
//
// So the AbaControl arm no longer asks the allocator for the collision - MOBILEGL_PIPE_HANDLE_ABA_CONTROL
// manufactures it, by replacing the object identity in each key with a constant (see
// MagmaPipeArms.h's MagmaPipeAbaControlDefeatsIdentity). That is the strongest form of "the
// allocator handed the block back", it is deterministic, and - the reason it matters - it defeats
// the {slot, gen} GENERATION as well as the retired lifetime id, so the control covers the key P2
// actually ships instead of only the one it replaced.
//
// AND THE ABA HAPPENS INSIDE ONE FRAME, which is not a detail. The only backend structure that can
// hand a draw a dead object's GPU slice is VulkanRenderer::ResolvedVertexBindings, and it refuses
// to be trusted across a frame boundary by design ("NO cross-frame trust"). Every other memo the
// recycle can poison holds LAYOUT, which is byte-identical between the two objects by construction
// and so cannot be seen in pixels. A reproducer that puts a frame boundary between the arming draw
// and the recycled draw therefore cannot produce wrong pixels no matter how completely the keys
// collide - it would be asserting a fact about the frame gate, not about identity.
//
// 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_HANDLE_ABA_CONTROL=1, on TWO lanes: one with MOBILEGL_PIPE_PUSH=0
// (the pre-handle arm, D18's lane verbatim) and one on the handle arm
// (MOBILEGL_PIPE_LEGACY_MEMOS=0). The knob defeats the object-identity half of
// every vertex-input memo key on whichever arm is running - the pre-handle
// (address, lifetime id) pair and the handle arm's {slot, gen} generation - so both
// lanes expect the CORRUPTION. Two lanes rather than one because the guard P2 SHIPS
// is the generation: a control that only defeated the retired guards would be green
// forever without saying anything about the re-key, which is exactly how this arm
// went vacuous once packages C and D landed.
//
// 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.
//
// Those two markers are a statement about the SOURCE TREE, and they are set only in a push build,
// because that is the only build in which the thing they name is compiled: the {slot, gen} re-key
// and Features.PipeHandleAbaControl are both `#if MOBILEGL_PIPE_PUSH`. In a pull build the two
// push arms therefore skip on MGITEST_PIPE_PUSH_BUILD before they ever look at a per-arm marker -
// otherwise, once C and D landed, the pull build would run AbaControl against guards that are
// still in force (a hard red on `ctest -L integration-gpu`, which G2 requires green in BOTH
// builds) and Handles against a library with no re-key in it (a green that asserts nothing).
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/PipeSlotPeek.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;
}
// Whether the lane named an arm this file knows. A value that is set but unrecognised is a
// FAILURE (SetUp below), never a quiet fall-through to Legacy: a typo in a lane's
// MGITEST_HANDLE_ARM would otherwise downgrade that lane's Handles or AbaControl assertion
// to the Legacy one, which passes - a lane reporting green for an arm it never ran. Same
// shape as CsoContentAddressingScenario's FAIL() on an unknown MGITEST_CSO_LANE.
bool ArmNameIsRecognised() {
const char* name = std::getenv(kArmMarker);
return name == nullptr || std::strcmp(name, "handles") == 0 ||
std::strcmp(name, "legacy") == 0 || std::strcmp(name, "aba") == 0;
}
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 SAME question for the BUFFER, and it is a different question. The marker above
// answers "is this backend's VERTEX-INPUT memo keyed on {slot, gen}", which P2 landed;
// a buffer only travels as a handle once the resource_* family does (P3a for Espryt,
// P7 for Magma), and until then a buffer's backend twin is still resolved from the
// frontend object. A buffer case that read the P2 marker would therefore report the
// Handles arm as armed on a tree where nothing about a buffer is keyed on a handle -
// green for a re-key that does not exist, which is the one outcome this file exists to
// prevent. Set by MG_IntegrationTest/CMakeLists.txt from a content probe for
// MGPipeResourceOps under each backend's own directory.
bool ThisBackendsResourceRekeyHasLanded() {
const std::string& backend = HeadlessGL::Get().BackendName();
if (backend == "DirectVulkan") {
return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_RESOURCES_DirectVulkan");
}
return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_RESOURCES_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) {
// Say which of the two was actually observed, on EVERY arm and whether or not the case
// passes. The arm's expectation is only half the evidence, and a reader of the CI log
// should not have to infer the other half from the exit status - least of all for a
// control whose whole claim is "the corruption is still reproducible here".
const bool sawStale = static_cast<bool>(RegionIsMostly(
image, kInset, image.Width() - kInset, kInset, image.Height() - kInset, stale, 0.0, when));
const bool sawFresh = static_cast<bool>(RegionIsMostly(
image, kInset, image.Width() - kInset, kInset, image.Height() - kInset, fresh, 0.0, when));
const bool expectsStale = arm == Arm::AbaControl && armExpectsCorruption;
std::cout << "[ HandleRecycle ] arm=" << ArmName(arm) << " expected="
<< (expectsStale ? "STALE" : "FRESH") << " observed="
<< (sawStale ? "STALE" : (sawFresh ? "FRESH" : "NEITHER")) << " (stale=" << stale
<< ", fresh=" << fresh << ") - " << when << std::endl;
if (expectsStale) {
// 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 identity half of every key is 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;
if (!ArmNameIsRecognised()) {
const char* raw = std::getenv(kArmMarker);
FAIL() << "unknown " << kArmMarker << " value '" << (raw != nullptr ? raw : "")
<< "': the arms are handles / legacy / aba. Reading an unrecognised name "
"as Legacy would make this lane assert the pre-re-key guards while "
"claiming to test something else, and it would pass.";
}
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.";
}
// Both push arms are compiled only under MOBILEGL_PIPE_PUSH, so in a pull build
// neither has anything to say whatever the source tree contains. This check comes
// BEFORE the per-arm markers deliberately: those answer "does the source tree
// implement it", which stops being a statement about this library the moment the
// library is the pull one. Without it, a pull build would run the Handles arm
// against a library with no {slot, gen} key (a green asserting nothing) and the
// AbaControl arm against one whose guards are still in force (a hard red on
// `ctest -L integration-gpu`, which G2 requires green in BOTH builds).
// MG_IntegrationTest/CMakeLists.txt already withholds the markers in a pull build;
// this is the second lock, so a hand-forced environment cannot arm them either.
if (m_arm != Arm::Legacy && !BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) {
GTEST_SKIP() << "the " << ArmName(m_arm)
<< " arm needs a library built with MOBILEGL_PIPE_PUSH, and this one "
"was not: the {slot, gen} re-key and Features.PipeHandleAbaControl "
"are both #if MOBILEGL_PIPE_PUSH (Config.h, ConfigLoader.cpp), so "
"there is nothing here for either arm to assert against. The lane "
"stays registered so that `ctest -L integration-gpu` names the same "
"tests in the pull build and the push build (gate G2); the Legacy "
"arm is the one that is meaningful here, and it runs.";
}
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: the build's capability probe found no "
"slot table and no Track H subsystem constant under "
"MobileGL/MG_Backend/"
<< Gl().BackendName()
<< " (P2 package C for DirectGLES, package D for DirectVulkan). The "
"arm is registered and visible, and arms itself when that "
"package lands in a push build.";
}
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;
}
}
// The buffer case's extra gate, on top of the arm gate above. Only the Handles arm
// needs it: `Legacy` asserts today's guards (which exist on every tree) and
// `AbaControl` is gated on the knob's consumer already.
void SkipUnlessTheResourceHandlePathIsAssertableHere() {
if (m_arm != Arm::Handles) return;
if (!ThisBackendsResourceRekeyHasLanded()) {
GTEST_SKIP() << "subsystem not implemented on this tree: the buffer's Handles arm "
"needs this backend's resource_* op table, and the build's capability "
"probe found no source under MobileGL/MG_Backend/"
<< Gl().BackendName()
<< " naming MGPipeResourceOps. Until it lands, a buffer's backend twin is "
"still resolved from the frontend BufferObject, so there is no "
"{slot, gen} buffer key here to assert about (P3a package C for "
"DirectGLES; Magma's buffer path is P7). The entry stays registered "
"and visible, and arms itself when that package lands in a push "
"build.";
}
}
// 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)));
}
// The same quad, split across TWO buffers - positions in one, colours in the other,
// one MGPVertexBuffer entry each. What travels in a P3a `set_vertex_buffers` is the
// per-binding BUFFER IDENTITY (D-H3: Res, Offset 0, the resolved stride and
// divisor); the formats live in the vertex-elements blob and are byte-identical
// between the two VAOs by construction. Splitting the set is what lets a PARTIAL
// inheritance be seen: with one buffer, a stale set and a stale everything look the
// same in the pixels.
GLuint MakePositionBuffer() {
const float positions[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};
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);
return buffer;
}
GLuint MakeColorBuffer(float r, float g, float b) {
const float colors[18] = {r, g, b, r, g, b, r, g, b, r, g, b, r, g, b, r, g, b};
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(colors), colors, GL_STATIC_DRAW);
return buffer;
}
void ConfigureSplitQuadVao(GLuint vao, GLuint positionBuffer, GLuint colorBuffer) {
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glBindBuffer(GL_ARRAY_BUFFER, colorBuffer);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr);
}
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. Only the VAO is recycled here: both buffers are created before the
// window and neither is deleted inside it, because buffer traffic in the window moves
// VkBufferManager's slice-epoch counter and that gate is not an identity gate (see
// the two MakeQuadBuffer calls). What is recycled is the GL NAME; the heap block is
// not handed back, which is why the knob - not the allocator - constructs the
// AbaControl arms' collision.
// ------------------------------------------------------------------------------------
TEST_F(HandleRecycleScenario, AVertexArrayAtARecycledAddressDoesNotInheritItsPredecessorsVertexInput) {
if (!Ready()) return;
SkipUnlessTheArmIsAssertableHere();
if (IsSkipped()) return;
// BOTH buffers are created, and both are DRAWN WITH, before the recycle happens.
// Creating a buffer - or touching one for the first time - moves VkBufferManager's
// manager-wide slice-epoch counter, and a moved counter sends the resolved-bindings
// memo into a revalidation that re-reads every binding from the live VAO. That gate is
// not an identity gate and it is not what this case is about, so both buffers are
// realised up front and the ABA window contains no buffer traffic at all.
const GLuint redBuffer = MakeQuadBuffer(1.0f, 0.0f, 0.0f);
const GLuint greenBuffer = MakeQuadBuffer(0.0f, 1.0f, 0.0f);
GLuint primerVao = 0;
glGenVertexArrays(1, &primerVao);
ConfigureQuadVao(primerVao, greenBuffer);
const Image primed = DrawQuadAndRead(primerVao);
ExpectWholeViewportIs(primed, "green", "priming the replacement's buffer");
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));
}
// ---- the ABA window: ONE frame, two draws ----
//
// The arming draw and the recycled draw share a frame because
// ResolvedVertexBindings - the only memo that carries a GPU slice rather than a
// layout - declines across frames by design. See the header.
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(m_colorProgram);
glBindVertexArray(redVao);
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
// Unbind FIRST: a still-bound object keeps living, so the last SharedPtr would not
// drop and the object would not die here at all.
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glDeleteVertexArrays(1, &redVao);
// The replacement, immediately, byte-identically configured, and reading the OTHER
// buffer - so its pixels differ from its predecessor's by exactly the thing a stale
// vertex binding would get wrong.
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";
glBindVertexArray(greenVao);
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
const Image image = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame();
// The name proxy. It no longer constructs the AbaControl arm's collision - the knob
// does that, deterministically, because the heap block is never handed back (header) -
// but it is still what makes this a RECYCLE rather than two unrelated objects, and it
// is what the Handles and Legacy arms are asserting is not enough to inherit anything.
if (greenVao != redVao) {
GTEST_SKIP() << "inconclusive, not proven: glGenVertexArrays returned " << greenVao
<< " rather than the deleted " << redVao << ", so no ABA was constructed";
}
RecordProperty("recycled_vao_name", static_cast<int>(greenVao));
ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/true, image, "green", "red",
"the draw after the VAO was recycled inside one frame");
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
GLuint cleanupVaos[2] = {greenVao, primerVao};
glDeleteVertexArrays(2, cleanupVaos);
GLuint cleanupBuffers[2] = {redBuffer, greenBuffer};
glDeleteBuffers(2, cleanupBuffers);
}
// ------------------------------------------------------------------------------------
// 1a. The same recycle, over a vertex-input SET of TWO buffers - the shape a P3a
// `set_vertex_buffers` actually has.
//
// The case above swaps the ONE buffer its VAO reads, so "inherited the dead VAO's
// vertex input" and "inherited the dead VAO's everything" are the same picture. P3a
// splits that record in two: the formats travel once per configuration, in
// `create_vertex_elements`' blob, and the per-binding BUFFER IDENTITIES travel in
// `set_vertex_buffers` (D-G2, D-H3). So the replacement here shares its predecessor's
// POSITION buffer and differs in the COLOUR buffer alone: the elements blob is
// byte-identical between the two VAOs, exactly one entry of the buffer set moved, and
// a replacement that inherited the dead VAO's set draws its own geometry in the dead
// VAO's colour. A single-buffer window cannot produce that picture.
//
// A SEPARATE CASE RATHER THAN A SECOND WINDOW IN THE ONE ABOVE, and the reason is
// measured. gtest_discover_tests registers one ctest entry per case, so a case is a
// PROCESS; the AbaControl knob collapses every VAO in a process onto one memo entry,
// and that entry carries the resolved vertex-input LAYOUT as well as the bindings.
// Two windows in one process therefore means the second window's VAOs inherit the
// first window's layout - and these VAOs deliberately do NOT share the first's
// (interleaved stride 20 there, two tight arrays here). Run as a second phase, the
// AbaControl arms read the split VAOs' vertices through the interleaved layout and
// every draw in the phase, priming and warm-up included, came back as garbage
// (measured: "should be all green ... first offender is blue"). That is not the
// identity claim failing, it is the arm's own knob poisoning the setup - so the
// window gets a process of its own, where every VAO carries the same layout and the
// buffer identity is again the only thing that differs.
// ------------------------------------------------------------------------------------
TEST_F(HandleRecycleScenario,
AVertexArrayAtARecycledAddressDoesNotInheritItsPredecessorsVertexBufferSet) {
if (!Ready()) return;
SkipUnlessTheArmIsAssertableHere();
if (IsSkipped()) return;
const GLuint positionBuffer = MakePositionBuffer();
const GLuint redColorBuffer = MakeColorBuffer(1.0f, 0.0f, 0.0f);
const GLuint greenColorBuffer = MakeColorBuffer(0.0f, 1.0f, 0.0f);
// Same rule as the case above: every buffer is realised and DRAWN WITH before the
// window, so the window contains no buffer traffic and the slice-epoch gate - which
// is not an identity gate - is not what decides the verdict.
GLuint splitPrimerVao = 0;
glGenVertexArrays(1, &splitPrimerVao);
ConfigureSplitQuadVao(splitPrimerVao, positionBuffer, greenColorBuffer);
const Image splitPrimed = DrawQuadAndRead(splitPrimerVao);
ExpectWholeViewportIs(splitPrimed, "green", "priming the split replacement's colour buffer");
GLuint splitRedVao = 0;
glGenVertexArrays(1, &splitRedVao);
ConfigureSplitQuadVao(splitRedVao, positionBuffer, redColorBuffer);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the split VAO left a GL error behind";
for (int frame = 0; frame < kWarmupFrames; ++frame) {
const Image warm = DrawQuadAndRead(splitRedVao);
ExpectWholeViewportIs(warm, "red", "split warm-up frame " + std::to_string(frame));
}
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(m_colorProgram);
glBindVertexArray(splitRedVao);
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glDeleteVertexArrays(1, &splitRedVao);
GLuint splitGreenVao = 0;
glGenVertexArrays(1, &splitGreenVao);
ConfigureSplitQuadVao(splitGreenVao, positionBuffer, greenColorBuffer);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR))
<< "building the split replacement VAO left a GL error behind";
glBindVertexArray(splitGreenVao);
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
const Image splitImage = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame();
if (splitGreenVao != splitRedVao) {
GTEST_SKIP() << "inconclusive, not proven: glGenVertexArrays returned " << splitGreenVao
<< " rather than the deleted " << splitRedVao
<< ", so no ABA was constructed for the split vertex-buffer set";
}
RecordProperty("recycled_split_vao_name", static_cast<int>(splitGreenVao));
ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/true, splitImage, "green", "red",
"the draw after a VAO reading a two-buffer vertex set was recycled inside "
"one frame");
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
GLuint splitCleanupVaos[2] = {splitGreenVao, splitPrimerVao};
glDeleteVertexArrays(2, splitCleanupVaos);
GLuint splitCleanupBuffers[3] = {positionBuffer, redColorBuffer, greenColorBuffer};
glDeleteBuffers(3, splitCleanupBuffers);
}
// ------------------------------------------------------------------------------------
// 1b. The BUFFER (P3a). Today a buffer's backend twin is reached from the frontend
// object: Espryt keys GLESBufferResource off the BufferObject's SharedPtr and
// re-probes it per draw through IsBufferDrawClean, and Magma mixes the
// BufferObject's lifetime id into VertexInputStateFactory's content hash - the guard
// commit 66b3b6e2 added after a destroyed buffer's GPU slice was bound for its
// successor's draw. P3a replaces that reachability with a client-minted handle: the
// store lives in a slot table, ~BufferObject emits `resource_destroy` and THEN frees
// the slot (D-L), and the next buffer is handed the same slot with Gen + 1.
//
// This case is the pixel-level question about that swap: does a buffer created
// immediately after another one died, at the same GL name and the same {slot}, get
// its own bytes? It is the buffer twin of the vertex-array case above and it is
// written FIRST, against the P3a contract commit, so that whatever the Legacy arm
// reports here is on the record before package C touches a backend.
//
// THE VAO IS NOT RECYCLED HERE - it is created once and outlives the whole case.
// Only the buffer dies. That is also why the VAO's attributes are PARKED on a
// buffer that never dies before the delete: a VAO attribute holds a
// SharedPtr<BufferObject> (MGPipeValueTypes.h:516), so while the VAO still points at
// the doomed buffer the frontend object cannot die, glDeleteBuffers only unnames it,
// and there would be no recycle to construct at all.
// ------------------------------------------------------------------------------------
TEST_F(HandleRecycleScenario, ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents) {
if (!Ready()) return;
SkipUnlessTheArmIsAssertableHere();
if (IsSkipped()) return;
SkipUnlessTheResourceHandlePathIsAssertableHere();
if (IsSkipped()) return;
// Both survivors are realised and drawn with before the window, for the reason the
// vertex-array case gives: a buffer touched for the first time moves the manager-wide
// slice epoch, and that gate is not an identity gate.
const GLuint parkingBuffer = MakeQuadBuffer(0.0f, 0.0f, 1.0f);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
ConfigureQuadVao(vao, parkingBuffer);
const Image parked = DrawQuadAndRead(vao);
ExpectWholeViewportIs(parked, "blue", "priming the parking buffer");
const GLuint redBuffer = MakeQuadBuffer(1.0f, 0.0f, 0.0f);
ConfigureQuadVao(vao, redBuffer);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first buffer left a GL error behind";
for (int frame = 0; frame < kWarmupFrames; ++frame) {
const Image warm = DrawQuadAndRead(vao);
ExpectWholeViewportIs(warm, "red", "warm-up frame " + std::to_string(frame));
}
// ---- the ABA window: ONE frame, two draws ----
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(m_colorProgram);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
// Let go of the doomed buffer - from the VAO's attributes and from the binding point -
// and only then delete it, so the frontend object really dies here.
ConfigureQuadVao(vao, parkingBuffer);
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
GLuint doomed = redBuffer;
glDeleteBuffers(1, &doomed);
// The replacement, immediately, with the same size and the same layout - so a store
// pooled by size, a twin resolved by identity or a content hash over the
// configuration all match the dead buffer's - and DIFFERENT CONTENTS, which is the
// only thing that differs and the only thing the pixels can show.
const GLuint greenBuffer = MakeQuadBuffer(0.0f, 1.0f, 0.0f);
ConfigureQuadVao(vao, greenBuffer);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR))
<< "building the replacement buffer left a GL error behind";
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
const Image image = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame();
if (greenBuffer != redBuffer) {
GTEST_SKIP() << "inconclusive, not proven: glGenBuffers returned " << greenBuffer
<< " rather than the deleted " << redBuffer << ", so no ABA was constructed";
}
RecordProperty("recycled_buffer_name", static_cast<int>(greenBuffer));
// The AbaControl arm expects the CORRUPTION here, and that this window can produce
// it at all is a measurement rather than an assumption. MOBILEGL_PIPE_HANDLE_ABA_CONTROL
// has two consumers, both Magma's, and the one that decides this case is
// VertexInputStateFactory::ComputeHash's `bufferKey = 0`: with the BUFFER's identity
// gone from the vertex-input content hash, TryBindResolvedVertexBindings accepts a
// binding resolved from the dead buffer as proof that it still reads the live one -
// the exact defect that hash was fixed for. The open question was whether the
// guards the control deliberately leaves standing would mask it, because one of
// them, VkBufferManager's manager-wide slice epoch, MOVES when the replacement is
// created and the replacement is created INSIDE this window by construction (a
// buffer ABA cannot be built without creating a buffer in it). It does not: on the
// contract tree both AbaControl lanes read the dead buffer's colour over 100% of
// the viewport ("first offender at (2,2) is red rgba(255,0,0,255)"). So the control
// reaches the buffer path too, and the line below records what was observed on
// EVERY arm, whether or not the case passes.
//
// What it does NOT reach is Espryt - the knob has no DirectGLES consumer, which is
// why the AbaControl arm is registered on DirectVulkan lanes only - nor the
// {slot, gen} GENERATION, for the reason MagmaPipeAbaControlDefeatsIdentity gives.
// Package C's buffer re-key is where a Features.PipeHandleAbaControl consumer over
// the resource slot table would go, the way MagmaPipeClaimSlotMemos is Magma's.
ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/true, image, "green", "red",
"the draw after the buffer was recycled inside one frame");
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glDeleteVertexArrays(1, &vao);
GLuint cleanupBuffers[2] = {parkingBuffer, greenBuffer};
glDeleteBuffers(2, cleanupBuffers);
}
// ------------------------------------------------------------------------------------
// 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);
}
// ------------------------------------------------------------------------------------
// P3a C-1: the recycle has to give the SLOT back, not only refuse to alias.
//
// Everything above asks "did the replacement inherit the dead object's state". This asks
// the other half of the same identity contract, which no case in this file could see:
// when the dead object is not replaced at all, does the client hand its {slot, gen}
// back? Until C-1 the answer under DirectVulkan was NO. The mint is the client's
// (MGPipeVertexInputEmitter::EmitVertexElements acquires a VertexElementsCso slot at
// every validate point with a VAO bound) and the only free in the tree was Espryt's
// StateObjectDeathOps consumer - so under Magma, which installs none deliberately
// (MagmaPipeArms.h: "an allocator here would grow by one SlotState plus one map node
// per object EVER created, for the life of the process, on a platform with an LMK"),
// every VAO ever created held its slot and its ~1.3 KB applier record until the process
// died, on the shipped 0x1ff mask, until create_vertex_elements began tripping
// Fatal{ProtocolCorruption} permanently at kMGPipeMaxVertexElementsSlots.
//
// THE OBSERVABLE IS THE ALLOCATOR, not pixels: this leak produces correct pictures the
// whole way to the fatal, which is exactly why the four cases above ran green over it.
// It runs on BOTH backends' Handles lanes; DirectVulkan is where it was red.
TEST_F(HandleRecycleScenario, DestroyedVertexArraysReturnTheirVertexElementsSlots) {
if (!Ready()) return;
SkipUnlessTheArmIsAssertableHere();
if (IsSkipped()) return;
if (m_arm != Arm::Handles) {
GTEST_SKIP() << "the client mints a VertexElementsCso slot only when the vertex-input "
"subsystem is on, and only the Handles arm pins it (0x1ff). The Legacy "
"and AbaControl lanes run MOBILEGL_PIPE_PUSH=0, where there is no "
"allocator to leak from.";
}
unsigned probe = 0;
if (!MGITest::PeekPipeSlotLiveCount(MGITest::PipeSlotKind::VertexElementsCso, &probe)) {
GTEST_SKIP() << "the client slot allocator is out of reach from this module (a pull "
"build has none, and the Android link resolves no internal symbol), so "
"'could not look' would be reported as 'did not leak'";
}
// One shared VBO: the case is about the VAOs, and a per-round buffer would churn the
// Buffer kind's slots alongside them and blur which allocator answered.
const GLuint buffer = MakeQuadBuffer(0.0f, 1.0f, 0.0f);
// One round: create a vertex array, DRAW with it - which is what mints the slot and
// publishes the applier's record; a VAO that never reaches a validate point has
// neither - unbind it and delete it. glGenVertexArrays hands the same name back every
// time, exactly as a chunk renderer's does, so a death path that keyed on the GL NAME
// rather than on the lifetime id would look correct here too, which is why the
// assertion is on the allocator and not on the name.
unsigned peakLive = 0;
const auto round = [&](const char* when, bool checkPixels) {
GLuint vao = 0;
glGenVertexArrays(1, &vao);
ConfigureQuadVao(vao, buffer);
const Image image = DrawQuadAndRead(vao);
if (checkPixels) {
// One picture check, so a green here cannot mean "the draws never happened
// and therefore nothing was ever minted".
ExpectWholeViewportIs(image, "green", when);
}
unsigned live = 0;
if (MGITest::PeekPipeSlotLiveCount(MGITest::PipeSlotKind::VertexElementsCso, &live) &&
live > peakLive) {
peakLive = live;
}
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
};
// TWO WARM-UP ROUNDS BEFORE THE BASELINE IS TAKEN, so what is measured is growth WITH
// the churn and not the one-off cost of drawing at all. The first draws in a process
// mint slots that legitimately never come back inside this case - the DEFAULT vertex
// array's above all, which this scenario's frames bind and which lives as long as the
// context does - and the second round is what proves the steady state has been
// reached, since a per-round leak would still be growing at that point. Sampling
// before them would score a one-off as the churn's leak; sampling after makes the
// assertion the exact one that matters: "N more create/destroy cycles cost ZERO more
// slots", with no slack in it.
round("the first warm-up draw", /*checkPixels=*/true);
round("the second warm-up draw", /*checkPixels=*/false);
peakLive = 0;
unsigned liveBefore = 0;
unsigned highWaterBefore = 0;
ASSERT_TRUE(MGITest::PeekPipeSlotLiveCount(MGITest::PipeSlotKind::VertexElementsCso,
&liveBefore));
ASSERT_TRUE(MGITest::PeekPipeSlotHighWater(MGITest::PipeSlotKind::VertexElementsCso,
&highWaterBefore));
constexpr int kChurn = 48;
for (int i = 0; i < kChurn; ++i) round("a churn draw", /*checkPixels=*/false);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the churn left a GL error behind";
unsigned liveAfter = 0;
unsigned highWaterAfter = 0;
ASSERT_TRUE(MGITest::PeekPipeSlotLiveCount(MGITest::PipeSlotKind::VertexElementsCso,
&liveAfter));
ASSERT_TRUE(MGITest::PeekPipeSlotHighWater(MGITest::PipeSlotKind::VertexElementsCso,
&highWaterAfter));
std::cout << "[ HandleRecycle ] backend=" << Gl().BackendName() << " VertexElementsCso live "
<< liveBefore << " -> " << liveAfter << " (peak " << peakLive << "), high water "
<< highWaterBefore << " -> " << highWaterAfter << " over " << kChurn
<< " create/draw/destroy rounds" << std::endl;
EXPECT_EQ(liveAfter, liveBefore)
<< kChurn << " vertex arrays were created, drawn with and destroyed and " << (liveAfter - liveBefore)
<< " VertexElementsCso slots never came back. Each one holds a SlotState, a "
"lifetime-id map node and the applier's ~1.3 KB record for the life of the "
"process, and past kMGPipeMaxVertexElementsSlots every create_vertex_elements "
"trips Fatal{ProtocolCorruption} for good. Backend "
<< Gl().BackendName();
EXPECT_EQ(highWaterAfter, highWaterBefore)
<< "the CSO slot space grew with the churn instead of recycling the one slot the "
"warm-up round already handed out; the frees are not reaching the allocator's "
"free list";
EXPECT_LE(peakLive - liveBefore, 1u)
<< "more than one churned vertex array was live at the allocator at once, so the "
"deaths are arriving late rather than at the destructor";
glDeleteBuffers(1, &buffer);
}
} // namespace
} // namespace MGITest