mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-13 14:48:32 +09:00
2287 lines
135 KiB
C++
2287 lines
135 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 <functional>
|
|
#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; }
|
|
|
|
// Does THIS LANE run with a live client slot allocator behind it - i.e. did it pin a
|
|
// non-zero MOBILEGL_PIPE_PUSH?
|
|
//
|
|
// The leak cases below need this and not the arm (review F-m4). The arm says which key a
|
|
// pixel assertion is about; the leak assertion is not about a key at all, it is about the
|
|
// allocator, and "the allocator has slots to leak" is exactly "the mask is not zero". The
|
|
// two questions almost coincide - DirectGLES/DirectVulkan.HandleRecycle.Legacy. and
|
|
// DirectVulkan.HandleRecycle.AbaControl. do pin MOBILEGL_PIPE_PUSH=0 - but
|
|
// DirectVulkan.HandleRecycle.AbaControlHandles. pins the shipping 0x1fff WITH a live
|
|
// allocator, and gating on `arm == Handles` declined it while telling the reader the
|
|
// lane had no allocator, which was false. Reading the lane's own pin covers all three.
|
|
//
|
|
// The ENVIRONMENT is the right place to read it from and the library's config is not:
|
|
// MG_Config is inside the library, this module links the shipping .so on Android, and the
|
|
// pin is the LANE's statement about what it configured. An entry that pinned nothing (the
|
|
// ambient ones) is not a lane and answers false - the build default may well be non-zero
|
|
// there, but an ambient entry configured no arm, no allocator expectation and no private
|
|
// log, which is the reason the whole file declines them.
|
|
bool LanePinnedALiveAllocator() {
|
|
const char* mask = std::getenv("MOBILEGL_PIPE_PUSH");
|
|
if (mask == nullptr || mask[0] == '\0') return false;
|
|
// strtoull handles the 0x form every lane spells it in, and a value this module
|
|
// cannot parse is treated as "no pin" rather than as a non-zero mask.
|
|
char* end = nullptr;
|
|
const unsigned long long value = std::strtoull(mask, &end, 0);
|
|
return end != nullptr && *end == '\0' && value != 0ull;
|
|
}
|
|
|
|
// ---- which of the allocator's TWO spaces a leak case measures --------------------
|
|
//
|
|
// Every kind but ShaderCso has one space. ShaderCso has two: the ordinary program slots,
|
|
// and the reserved high band the program-pipeline COMPOSITES are minted out of through
|
|
// the allocator's one door, AllocateComposite (D-H7). c0b split their high-water marks
|
|
// (contract-v2.md 4.3) precisely so that a leak case can be written about either, and
|
|
// the composite's case has to read the BAND's - a band slot that never comes back moves
|
|
// neither of the ordinary numbers, which is the review's F-M4: the case would have
|
|
// reported green having never looked at the thing it exists for.
|
|
//
|
|
// Stated at every call site rather than defaulted, for PipeSlotPeek's `no default:`
|
|
// reason: a new leak case must say which space it is about, because the wrong answer is
|
|
// a green that asserts nothing rather than a compile error.
|
|
enum class SlotSpace {
|
|
Ordinary,
|
|
CompositeBand,
|
|
};
|
|
|
|
const char* SpaceSuffix(SlotSpace space) {
|
|
return space == SlotSpace::CompositeBand ? " [composite band]" : "";
|
|
}
|
|
|
|
bool ReadSpaceLiveCount(PipeSlotKind kind, SlotSpace space, unsigned* out) {
|
|
return space == SlotSpace::CompositeBand ? MGITest::PeekPipeCompositeSlotLiveCount(out)
|
|
: MGITest::PeekPipeSlotLiveCount(kind, out);
|
|
}
|
|
|
|
bool ReadSpaceHighWater(PipeSlotKind kind, SlotSpace space, unsigned* out) {
|
|
return space == SlotSpace::CompositeBand ? MGITest::PeekPipeCompositeSlotHighWater(out)
|
|
: MGITest::PeekPipeSlotHighWater(kind, out);
|
|
}
|
|
|
|
// The value a space's high-water mark has when NOTHING of it was ever handed out: 0 for
|
|
// the ordinary space, and the band's BASE for the band, because CompositeHighWater() is
|
|
// an absolute slot number. Reading this wrong is what would turn the band's "nothing was
|
|
// ever minted" skip into a silent pass on a tree that mints composites.
|
|
bool ReadSpaceHighWaterFloor(SlotSpace space, unsigned* out) {
|
|
if (space != SlotSpace::CompositeBand) {
|
|
*out = 0;
|
|
return true;
|
|
}
|
|
return MGITest::PeekPipeCompositeSlotBandBase(out);
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
// P4a's version of the SAME question, and it is a THIRD question rather than a rewording
|
|
// of either above. The P2 marker answers "is this backend's VERTEX-INPUT memo keyed on
|
|
// {slot, gen}"; the P3a one answers it for the BUFFER. P4a re-keys six more object classes
|
|
// - texture, renderbuffer, framebuffer, sampler CSO, sampler view and shader CSO - and
|
|
// their four subsystem bits (kMGPipeSubsystem{Framebuffer,TextureResources,Samplers,
|
|
// Programs}, MGPipe.h) are what a backend has to name to honour MOBILEGL_PIPE_PUSH's
|
|
// default mask. A P4a case that read either older marker would report the Handles arm as
|
|
// armed on a tree where nothing about a texture 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 over each backend's own
|
|
// directory, exactly like its two predecessors.
|
|
bool ThisBackendsObjectRekeyHasLanded() {
|
|
const std::string& backend = HeadlessGL::Get().BackendName();
|
|
if (backend == "DirectVulkan") {
|
|
return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_OBJECTS_DirectVulkan");
|
|
}
|
|
return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_OBJECTS_DirectGLES");
|
|
}
|
|
|
|
// "Does MOBILEGL_PIPE_HANDLE_ABA_CONTROL steer THIS backend's P4a OBJECT keys?" - the
|
|
// question that decides whether the AbaControl arm of a P4a case expects the corruption
|
|
// or the correct pixels, and it is deliberately narrow.
|
|
//
|
|
// WHY IT IS NOT THE EXISTING MGITEST_HANDLE_ABA_IMPLEMENTED, and this is the P4a finding
|
|
// the file records rather than works around. That marker says "some DirectVulkan source
|
|
// reads Features.PipeHandleAbaControl", and today exactly one does:
|
|
// MagmaPipeArms.h's MagmaPipeAbaControlDefeatsIdentity, whose consumers are Magma's
|
|
// VERTEX-INPUT keys. Magma mints {slot, gen} for two kinds only - VertexElementsCso and
|
|
// Buffer (MagmaPipeIdentityTables) - so there is no texture, framebuffer, sampler, view or
|
|
// program key on that backend for the knob to defeat, and P4a does not add one: Magma's
|
|
// object paths are P7 (BRIEF-P4A.md D-Q), and MG_Backend/DirectVulkan/** is untouched in
|
|
// P4a apart from MagmaPipeArms.h's own statement of this fact. On DirectGLES the knob has
|
|
// no consumer at all.
|
|
//
|
|
// So on this tree the six P4a cases below run their AbaControl arm with the knob INERT.
|
|
// The honest report for that is the arm asserting the correct pixels and SAYING that it is
|
|
// not controlling anything here - never a lane that expects a corruption nothing can
|
|
// produce, which would be a hard red on an always-on integration-gpu lane, which is
|
|
// exactly the failure this file's header records having had once. The moment a backend
|
|
// grows a Features.PipeHandleAbaControl consumer over its P4a object slot tables (one `if`
|
|
// in GetOrCreate / FindByHandle, the way MagmaPipeClaimSlotMemos is Magma's for vertex
|
|
// input), the probe finds it and every one of the six flips to expecting the corruption.
|
|
bool ObjectAbaControlIsWiredHere() {
|
|
const std::string& backend = HeadlessGL::Get().BackendName();
|
|
const bool knob = backend == "DirectVulkan"
|
|
? BuildMarkerIsSet("MGITEST_HANDLE_ABA_OBJECTS_DirectVulkan")
|
|
: BuildMarkerIsSet("MGITEST_HANDLE_ABA_OBJECTS_DirectGLES");
|
|
// Both halves, because either alone is a lie: a knob consumer with no object re-key
|
|
// has nothing to defeat, and an object re-key with no knob consumer cannot be defeated.
|
|
return knob && ThisBackendsObjectRekeyHasLanded();
|
|
}
|
|
|
|
// ---- 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.";
|
|
}
|
|
}
|
|
|
|
// P4a's version of the gate above, for the six object kinds. Only the Handles arm
|
|
// needs it: `Legacy` asserts today's address/weak_ptr guards (which exist on every
|
|
// tree) and `AbaControl` decides what it expects from ObjectAbaControlIsWiredHere().
|
|
void SkipUnlessTheObjectHandlePathIsAssertableHere(const char* kindName) {
|
|
if (m_arm != Arm::Handles) return;
|
|
if (!ThisBackendsObjectRekeyHasLanded()) {
|
|
GTEST_SKIP() << "subsystem not implemented on this tree: the " << kindName
|
|
<< "'s Handles arm needs this backend to be keyed on {slot, gen} "
|
|
"for P4a's object families, and the build's capability probe "
|
|
"found no source under MobileGL/MG_Backend/"
|
|
<< Gl().BackendName()
|
|
<< " naming any of kMGPipeSubsystem{Framebuffer, TextureResources, "
|
|
"Samplers, Programs}. Until they land, this object's backend "
|
|
"twin is still reached from the frontend object, so there is no "
|
|
"{slot, gen} key here to assert about (P4a packages D and E for "
|
|
"DirectGLES; Magma's object paths are P7). The entry stays "
|
|
"registered and visible, and arms itself when that package "
|
|
"lands in a push build.";
|
|
}
|
|
}
|
|
|
|
// Says, on EVERY arm and whether or not the case passes, whether the AbaControl arm is
|
|
// controlling anything for this kind on this backend - the same reason ExpectPixelsFor
|
|
// prints what it observed. A reader of a green AbaControl entry must not have to infer
|
|
// which of the two it was.
|
|
bool ObjectAbaExpectation(const char* kindName) {
|
|
const bool wired = ObjectAbaControlIsWiredHere();
|
|
if (m_arm == Arm::AbaControl) {
|
|
std::cout << "[ HandleRecycle ] aba_control kind=" << kindName
|
|
<< " backend=" << Gl().BackendName() << " wired=" << (wired ? "1" : "0")
|
|
<< (wired ? " (the knob defeats this kind's identity: the corruption IS "
|
|
"the assertion)"
|
|
: " (no Features.PipeHandleAbaControl consumer over this "
|
|
"backend's P4a object slot tables, so the knob is inert "
|
|
"here and this arm asserts the correct pixels - it is not "
|
|
"a control for this kind yet)")
|
|
<< std::endl;
|
|
RecordProperty("aba_control_wired", wired ? 1 : 0);
|
|
}
|
|
return wired;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// ---- G8b: the leak shape, spelled once ------------------------------------------
|
|
//
|
|
// DestroyedVertexArraysReturnTheirVertexElementsSlots below is the original; P4a adds
|
|
// one case per kind it mints, and seven copies of a twenty-line assertion block is how
|
|
// six of them quietly stop asserting the same thing. So the block lives here and each
|
|
// case supplies only its own churn round.
|
|
//
|
|
// `round(checkPixels, observe)` must create ONE object of the kind, put it through
|
|
// whatever makes the client mint its slot (which for every P4a kind means reaching a
|
|
// validate point - an object created and destroyed without a draw has no record and no
|
|
// slot), call `observe()` WHILE THE OBJECT IS STILL ALIVE, and then destroy it.
|
|
//
|
|
// `observe()` is where peakLive is sampled, and it has to be inside the round rather
|
|
// than after it: sampled after the destroy it would only ever see the resting count,
|
|
// and the "deaths arrive at the destructor rather than late" assertion below would be
|
|
// vacuous - which is the one of the three that catches a death path that works but
|
|
// runs at the wrong time (a deferred queue, a frame-boundary sweep).
|
|
//
|
|
// `space` says WHICH of the allocator's two spaces the three assertions are about, and
|
|
// it is the difference between an assertion and a green that reads the wrong counter:
|
|
// only ShaderCso has two, and only the composite case is about the band.
|
|
//
|
|
// `maxInFlight` is how many slots of the kind IN THAT SPACE one round may legitimately
|
|
// hold at its peak: 1 where the round creates one object of it, more where the round
|
|
// creates several. The composite round creates three ShaderCsos - two stage programs
|
|
// and the composite they are flattened into - but only ONE of the three is a band
|
|
// slot, so the band's answer is 1 and the ordinary space's would have been 3.
|
|
// `warmUpRounds` is how many rounds run BEFORE the baseline is taken, and for one
|
|
// kind it is not two.
|
|
//
|
|
// A CONTENT-ADDRESSED KIND'S SLOT IS NOT THE OBJECT'S (D-F1, ID-17's reference-count
|
|
// ruling). SamplerCso is minted by a CACHE keyed on the parameter block: destroying
|
|
// the frontend sampler releases its REFERENCE, and the entry then stays in the cache,
|
|
// unreferenced, until LRU eviction at capacity 256. So a churn over N DISTINCT
|
|
// parameter sets legitimately retains N slots however many objects carried them, and
|
|
// "48 objects, 14 slots not returned" is the cache working, not P3a's C-1 leak.
|
|
// Asserting the flat count there measures the cache's capacity policy and calls it a
|
|
// leak - which is exactly what this case did on the tree where package C landed.
|
|
//
|
|
// The fix is not a weaker assertion but a warmer cache: run enough warm-up rounds to
|
|
// walk EVERY distinct content once, so that the baseline is taken with the cache full
|
|
// and the measured churn re-uses entries that already exist. The three assertions
|
|
// below then say something STRONGER than they could for a per-object kind - a warm
|
|
// content-addressed cache must not grow AT ALL under churn - and an unbounded leak,
|
|
// which is what C-1 is about, still moves every one of them.
|
|
using ChurnRound = std::function<void(bool checkPixels, const std::function<void()>& observe)>;
|
|
void AssertChurnReturnsEverySlot(PipeSlotKind kind, SlotSpace space, const char* kindName,
|
|
const char* owner, unsigned maxInFlight,
|
|
const ChurnRound& round, unsigned warmUpRounds = 2u) {
|
|
// THE LANE'S OWN PIN, not the arm (F-m4). The Legacy and AbaControl lanes run
|
|
// MOBILEGL_PIPE_PUSH=0 and really have no allocator to leak from; the Handles
|
|
// lanes and DirectVulkan.HandleRecycle.AbaControlHandles. all pin the shipping
|
|
// 0x1fff and do. The old gate declined the third of those while telling the
|
|
// reader it had no allocator, which was false, and left one lane's coverage on
|
|
// the table.
|
|
if (!LanePinnedALiveAllocator()) {
|
|
GTEST_SKIP() << "this entry pinned no non-zero MOBILEGL_PIPE_PUSH, so there is "
|
|
"no client slot allocator behind it to leak from: the "
|
|
"HandleRecycle.Legacy. and HandleRecycle.AbaControl. lanes pin "
|
|
"MOBILEGL_PIPE_PUSH=0 on purpose (they are about the pre-handle "
|
|
"guards), and the ambient entries configure no lane at all. The "
|
|
"lanes that carry this assertion are the two "
|
|
"HandleRecycle.Handles. ones and "
|
|
"DirectVulkan.HandleRecycle.AbaControlHandles., all of which pin "
|
|
"the shipping mask.";
|
|
}
|
|
unsigned probe = 0;
|
|
if (!ReadSpaceLiveCount(kind, space, &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'";
|
|
}
|
|
unsigned highWaterFloor = 0;
|
|
if (!ReadSpaceHighWaterFloor(space, &highWaterFloor)) {
|
|
GTEST_SKIP() << "the composite band's base is out of reach from this module, so "
|
|
"'no composite was ever minted' cannot be told apart from 'the "
|
|
"band did not grow' and a green here would assert nothing";
|
|
}
|
|
|
|
// 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 rounds in a
|
|
// process mint slots that legitimately never come back inside this case - the
|
|
// default vertex array's, the scene's own program's - 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.
|
|
unsigned peakLive = 0;
|
|
const std::function<void()> observe = [&]() {
|
|
unsigned live = 0;
|
|
if (ReadSpaceLiveCount(kind, space, &live) && live > peakLive) peakLive = live;
|
|
};
|
|
// The first round checks pixels; the rest only churn. Two is the floor and the
|
|
// default (see the parameter's note): the first mints the one-off slots any draw
|
|
// needs and the second proves the steady state has been reached.
|
|
ASSERT_GE(warmUpRounds, 2u) << "the warm-up has to reach a steady state";
|
|
round(/*checkPixels=*/true, observe);
|
|
for (unsigned i = 1; i < warmUpRounds; ++i) round(/*checkPixels=*/false, observe);
|
|
peakLive = 0;
|
|
|
|
unsigned liveBefore = 0;
|
|
unsigned highWaterBefore = 0;
|
|
ASSERT_TRUE(ReadSpaceLiveCount(kind, space, &liveBefore));
|
|
ASSERT_TRUE(ReadSpaceHighWater(kind, space, &highWaterBefore));
|
|
|
|
constexpr int kChurn = 48;
|
|
for (int i = 0; i < kChurn; ++i) round(/*checkPixels=*/false, observe);
|
|
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the churn left a GL error behind";
|
|
|
|
unsigned liveAfter = 0;
|
|
unsigned highWaterAfter = 0;
|
|
ASSERT_TRUE(ReadSpaceLiveCount(kind, space, &liveAfter));
|
|
ASSERT_TRUE(ReadSpaceHighWater(kind, space, &highWaterAfter));
|
|
std::cout << "[ HandleRecycle ] backend=" << Gl().BackendName() << " " << kindName
|
|
<< SpaceSuffix(space) << " live " << liveBefore << " -> " << liveAfter
|
|
<< " (peak " << peakLive << "), high water " << highWaterBefore << " -> "
|
|
<< highWaterAfter << " (floor " << highWaterFloor << ") over " << kChurn
|
|
<< " create/draw/destroy rounds" << std::endl;
|
|
|
|
// NOTHING WAS EVER MINTED, which is not "did not leak" and must not be reported as
|
|
// one. On the P4a contract tree the client emits nothing for any of these kinds -
|
|
// the five emit headers are the contract's stubs (contract-v1 D1) - so every
|
|
// assertion below would be 0 == 0 and the case would be a green that asserts about
|
|
// a kind it never saw. This is the same rule as the peek returning false, applied
|
|
// to the other way of not being able to look, and it arms itself the moment the
|
|
// owning package's emitter lands.
|
|
if (highWaterAfter == highWaterFloor && peakLive == 0 && liveAfter == 0) {
|
|
GTEST_SKIP() << "subsystem not implemented on this tree: the client minted no "
|
|
<< kindName << SpaceSuffix(space)
|
|
<< " slot at all over " << (kChurn + 2)
|
|
<< " create/draw/destroy rounds, so there is nothing here that "
|
|
"could leak and a green would assert nothing. P4a package "
|
|
<< owner
|
|
<< " owns the emitter that mints it; this case arms itself when it "
|
|
"lands.";
|
|
}
|
|
|
|
EXPECT_EQ(liveAfter, liveBefore)
|
|
<< kChurn << " " << kindName << SpaceSuffix(space)
|
|
<< " objects were created, drawn with and destroyed and "
|
|
<< (liveAfter - liveBefore)
|
|
<< " slots never came back. Each one holds a SlotState, a lifetime-id map node "
|
|
"and the applier's record for the life of the process, and past the kind's "
|
|
"slot bound every create trips Fatal{ProtocolCorruption} for good "
|
|
"(PipeApply.h:97-113). This is P3a's C-1 defect, which is why every P4a kind "
|
|
"frees its slot from the frontend destructor through one client-side helper "
|
|
"(D-I1) rather than from a backend death table. Backend "
|
|
<< Gl().BackendName();
|
|
EXPECT_EQ(highWaterAfter, highWaterBefore)
|
|
<< "the " << kindName << SpaceSuffix(space)
|
|
<< " slot space grew with the churn instead of recycling the slot the warm-up "
|
|
"rounds already handed out; the frees are not reaching the allocator's free "
|
|
"list";
|
|
EXPECT_LE(peakLive > liveBefore ? peakLive - liveBefore : 0u, maxInFlight)
|
|
<< "more than " << maxInFlight << " churned " << kindName << SpaceSuffix(space)
|
|
<< " object(s) were live at the allocator at once, so the deaths are arriving "
|
|
"late rather than at the destructor";
|
|
}
|
|
|
|
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.
|
|
//
|
|
// P4a MADE THIS A REAL ABA CONTROL (G8, D-I2). Until P4a the case expected the correct
|
|
// pixels on EVERY arm, with the note that "the AbaControl knob does not steer this
|
|
// path" - true, and vacuous the moment P4a re-keys the texture twin on {slot, gen}: a
|
|
// control that only defeats guards nobody ships says nothing about the key that does.
|
|
// So the expectation is now ObjectAbaExpectation()'s answer - "expect the corruption"
|
|
// exactly where the knob really reaches this kind on this backend, "expect the correct
|
|
// pixels, and SAY that this arm is not a control here" where it does not. See
|
|
// ObjectAbaControlIsWiredHere for why the second is today's answer on both backends and
|
|
// for the one change that flips it.
|
|
// ------------------------------------------------------------------------------------
|
|
TEST_F(HandleRecycleScenario, ATextureAtARecycledAddressDoesNotInheritItsPredecessorsTwin) {
|
|
if (!Ready()) return;
|
|
SkipUnlessTheArmIsAssertableHere();
|
|
if (IsSkipped()) return;
|
|
SkipUnlessTheObjectHandlePathIsAssertableHere("texture");
|
|
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=*/ObjectAbaExpectation("texture"), 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.
|
|
//
|
|
// P4a MADE THIS A REAL ABA CONTROL TOO (G8, D-I2), and it needed one more thing than
|
|
// the texture case did: somewhere for the corruption to be VISIBLE. "The replacement's
|
|
// attachment never became green" is only half a verdict - it does not say where the
|
|
// clear went. So the dead framebuffer's attachment is cleared to RED in the warm-up and
|
|
// read back beside the replacement's at the end: FRESH is (second green, first red),
|
|
// STALE is (first green) - the clear reached a framebuffer this one only shares an
|
|
// address with. A framebuffer is the kind with a handle and NO wire lifetime (D-I2):
|
|
// the applier learns of it only through set_framebuffer_state keyed by Fbo, and a
|
|
// recycled handle is told apart by Gen, which is inside ContentHash - so the knob has
|
|
// to defeat the identity half of that memo key for this to reproduce at all.
|
|
// ------------------------------------------------------------------------------------
|
|
TEST_F(HandleRecycleScenario, AFramebufferAtARecycledAddressDoesNotInheritItsPredecessorsTwin) {
|
|
if (!Ready()) return;
|
|
SkipUnlessTheArmIsAssertableHere();
|
|
if (IsSkipped()) return;
|
|
SkipUnlessTheObjectHandlePathIsAssertableHere("framebuffer");
|
|
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 BOTH attachments, not the framebuffer: that is what makes "the clear landed in
|
|
// the dead framebuffer" visible as a place rather than as an absence.
|
|
const auto readAttachment = [&](GLuint texture) {
|
|
std::vector<std::uint8_t> texels(4 * 4 * 4, 0);
|
|
glBindTexture(GL_TEXTURE_2D, texture);
|
|
glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels.data());
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
return texels;
|
|
};
|
|
const auto offendersAgainst = [](const std::vector<std::uint8_t>& texels, int r, int g,
|
|
int b) {
|
|
int offenders = 0;
|
|
for (std::size_t i = 0; i < texels.size(); i += 4) {
|
|
if (texels[i] != r || texels[i + 1] != g || texels[i + 2] != b) ++offenders;
|
|
}
|
|
return offenders;
|
|
};
|
|
const std::vector<std::uint8_t> replacementTexels = readAttachment(secondAttachment);
|
|
const std::vector<std::uint8_t> deadTexels = readAttachment(firstAttachment);
|
|
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
|
|
|
|
const int replacementIsNotGreen = offendersAgainst(replacementTexels, 0, 255, 0);
|
|
const int deadIsNotRed = offendersAgainst(deadTexels, 255, 0, 0);
|
|
const bool sawStale = deadIsNotRed != 0 && offendersAgainst(deadTexels, 0, 255, 0) == 0;
|
|
std::cout << "[ HandleRecycle ] arm=" << ArmName(m_arm) << " framebuffer observed="
|
|
<< (sawStale ? "STALE (the clear reached the DEAD framebuffer's attachment)"
|
|
: (replacementIsNotGreen == 0
|
|
? "FRESH (the clear reached the replacement's own "
|
|
"attachment)"
|
|
: "NEITHER"))
|
|
<< std::endl;
|
|
|
|
if (ObjectAbaExpectation("framebuffer") && m_arm == Arm::AbaControl) {
|
|
// The corruption IS the assertion: with the identity half of the framebuffer memo
|
|
// key defeated, the replacement inherits the dead framebuffer's record and its
|
|
// clear lands on the dead one's attachment.
|
|
// sawStale, not `deadIsNotRed != 0` (review F-m12): the weaker form is satisfied
|
|
// by a dead attachment full of GARBAGE, which is not the observation this control
|
|
// claims. sawStale is the predicate the case already computes and prints - the
|
|
// dead attachment is now the REPLACEMENT'S green, i.e. the replacement's clear
|
|
// landed there - so the assertion and the printed line say the same thing.
|
|
EXPECT_TRUE(sawStale)
|
|
<< "[AbaControl expects the STALE framebuffer] the DEAD framebuffer's "
|
|
"attachment did not come back as the replacement's green, so the "
|
|
"replacement's clear did not land on it and the ABA was not reproduced - "
|
|
"the control has stopped controlling anything. Texels that are neither the "
|
|
"warm-up red nor the replacement green are a third answer and are not a "
|
|
"reproduction either ("
|
|
<< deadIsNotRed << " of " << (deadTexels.size() / 4)
|
|
<< " dead texels are not red).";
|
|
} else {
|
|
EXPECT_EQ(replacementIsNotGreen, 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>(replacementTexels[0]) << ","
|
|
<< static_cast<int>(replacementTexels[1]) << ","
|
|
<< static_cast<int>(replacementTexels[2]) << ","
|
|
<< static_cast<int>(replacementTexels[3]) << ")";
|
|
EXPECT_EQ(deadIsNotRed, 0)
|
|
<< "the DEAD framebuffer's attachment changed colour, so the replacement's "
|
|
"clear reached the framebuffer it only shares a name with";
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ====================================================================================
|
|
// P4a (G8, G8b). Six more kinds, and the same two questions about each of them.
|
|
//
|
|
// WHAT IS NEW HERE, stated once so the cases below can stay short. P4a mints Texture,
|
|
// Renderbuffer, Framebuffer, SamplerCso, SamplerViewCso and ShaderCso handles on the
|
|
// client and frees every one of them from the frontend object's own destructor, through
|
|
// one helper per kind, in one fixed order: emit the wire delete, raise the death notice,
|
|
// free the slot (D-I1). That shape exists because of P3a's C-1 - a client-minted CSO whose
|
|
// only free was a backend death table Magma does not install, which leaked a slot and a
|
|
// ~1.3 KB record per VAO until the process Fatal'd - so the leak cases below run on the
|
|
// DirectVulkan Handles lane as well as the DirectGLES one, exactly as ID-8 requires.
|
|
//
|
|
// Two kinds carry a wrinkle the others do not:
|
|
// * FRAMEBUFFER has a handle and NO wire lifetime (D-I2). There is no framebuffer row in
|
|
// the call catalogue at all: the applier learns of one only through
|
|
// set_framebuffer_state keyed by Fbo, and the death helper does the notice and the
|
|
// free and emits nothing. The allocator is therefore the ONLY observable of a
|
|
// framebuffer's lifetime, which is what the leak case reads.
|
|
// * SHADERCSO covers ordinary programs AND the program-pipeline COMPOSITES minted from
|
|
// the reserved high band (D-H7). A composite's slot has TWO independent release paths
|
|
// - the pipeline cache's LRU eviction and the composite ProgramObject's destructor -
|
|
// which is why it gets a case of its own; the second free is a proven no-op
|
|
// (SlotAllocator.cpp:117-119) and this is where "proven" is measured rather than
|
|
// asserted in a comment.
|
|
// ====================================================================================
|
|
|
|
// ------------------------------------------------------------------------------------
|
|
// 4. The renderbuffer. Espryt keeps a backend twin per frontend renderbuffer in the same
|
|
// address-keyed registry the texture uses, and P4a re-keys it on {slot, gen}.
|
|
//
|
|
// The replacement is deliberately a DIFFERENT SIZE, because a renderbuffer cannot be
|
|
// sampled and so has no colour of its own to inherit: what a stale twin gets wrong is
|
|
// the STORAGE, and the storage's extent is the one property a readback can see. 8x8
|
|
// green against a dead 4x4 red: fresh reads green everywhere, and a replacement that
|
|
// inherited the dead 4x4 driver renderbuffer either leaves the outer ring unwritten or
|
|
// makes the framebuffer incomplete - both of which this case reports as STALE.
|
|
// ------------------------------------------------------------------------------------
|
|
TEST_F(HandleRecycleScenario, ARenderbufferAtARecycledAddressDoesNotInheritItsPredecessorsStorage) {
|
|
if (!Ready()) return;
|
|
SkipUnlessTheArmIsAssertableHere();
|
|
if (IsSkipped()) return;
|
|
SkipUnlessTheObjectHandlePathIsAssertableHere("renderbuffer");
|
|
if (IsSkipped()) return;
|
|
|
|
GLuint fbo = 0;
|
|
glGenFramebuffers(1, &fbo);
|
|
|
|
GLuint deadRenderbuffer = 0;
|
|
glGenRenderbuffers(1, &deadRenderbuffer);
|
|
glBindRenderbuffer(GL_RENDERBUFFER, deadRenderbuffer);
|
|
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 4, 4);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
|
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER,
|
|
deadRenderbuffer);
|
|
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
|
|
for (int frame = 0; frame < kWarmupFrames; ++frame) {
|
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
|
glViewport(0, 0, 4, 4);
|
|
ClearTo(1.0f, 0.0f, 0.0f, 1.0f);
|
|
BindDefaultFramebuffer();
|
|
Gl().EndFrame();
|
|
}
|
|
|
|
// Detach BEFORE deleting: an attached renderbuffer is kept alive by the frontend
|
|
// FramebufferAttachmentObject's SharedPtr, so glDeleteRenderbuffers would only unname
|
|
// it and the object would not die here at all.
|
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
|
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, 0);
|
|
BindDefaultFramebuffer();
|
|
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
|
glDeleteRenderbuffers(1, &deadRenderbuffer);
|
|
|
|
GLuint liveRenderbuffer = 0;
|
|
glGenRenderbuffers(1, &liveRenderbuffer);
|
|
if (liveRenderbuffer != deadRenderbuffer) {
|
|
glDeleteRenderbuffers(1, &liveRenderbuffer);
|
|
glDeleteFramebuffers(1, &fbo);
|
|
GTEST_SKIP() << "inconclusive, not proven: glGenRenderbuffers returned "
|
|
<< liveRenderbuffer << " rather than the deleted " << deadRenderbuffer
|
|
<< ", so no ABA was constructed";
|
|
}
|
|
RecordProperty("recycled_renderbuffer_name", static_cast<int>(liveRenderbuffer));
|
|
|
|
glBindRenderbuffer(GL_RENDERBUFFER, liveRenderbuffer);
|
|
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 8, 8);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
|
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER,
|
|
liveRenderbuffer);
|
|
const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
|
|
glViewport(0, 0, 8, 8);
|
|
ClearTo(0.0f, 1.0f, 0.0f, 1.0f);
|
|
const Image image = ReadPixels(8, 8);
|
|
BindDefaultFramebuffer();
|
|
Gl().EndFrame();
|
|
|
|
const bool complete = status == GLenum(GL_FRAMEBUFFER_COMPLETE);
|
|
const bool allGreen = complete && static_cast<bool>(RegionIsMostly(image, 0, 8, 0, 8,
|
|
"green", 0.0,
|
|
"the replacement "
|
|
"renderbuffer"));
|
|
std::cout << "[ HandleRecycle ] arm=" << ArmName(m_arm)
|
|
<< " renderbuffer observed=" << (allGreen ? "FRESH (8x8 all green)"
|
|
: "STALE (the replacement did not "
|
|
"get its own 8x8 storage)")
|
|
<< " fbo_status=0x" << std::hex << status << std::dec << std::endl;
|
|
|
|
if (ObjectAbaExpectation("renderbuffer") && m_arm == Arm::AbaControl) {
|
|
EXPECT_FALSE(allGreen)
|
|
<< "[AbaControl expects the STALE renderbuffer] the replacement got its own 8x8 "
|
|
"storage with the identity half of the twin key deliberately defeated, so "
|
|
"the ABA was not reproduced and this control is controlling nothing.";
|
|
} else {
|
|
EXPECT_EQ(status, GLenum(GL_FRAMEBUFFER_COMPLETE))
|
|
<< "the framebuffer went incomplete after the recycled renderbuffer was given "
|
|
"8x8 storage, which is what a driver object inherited from the dead 4x4 "
|
|
"renderbuffer looks like";
|
|
EXPECT_TRUE(RegionIsMostly(image, 0, 8, 0, 8, "green", 0.0,
|
|
"the replacement renderbuffer's own 8x8 storage"))
|
|
<< "the replacement renderbuffer did not read back as its own storage, so the "
|
|
"clear reached a renderbuffer it only shares a name with";
|
|
}
|
|
|
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
|
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
|
glDeleteRenderbuffers(1, &liveRenderbuffer);
|
|
glDeleteFramebuffers(1, &fbo);
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------
|
|
// 5. The sampler object (SamplerCso). P4a content-addresses sampler CSOs at capacity 256
|
|
// and hashes them field-wise over a canonical zero-initialised copy (D-F1), so two
|
|
// sampler objects with identical parameters are ONE CSO by design - which is why the
|
|
// ABA here is built out of two samplers whose parameters DIFFER.
|
|
//
|
|
// The observable is the BORDER COLOUR, sampled at a constant UV outside [0,1] with
|
|
// GL_CLAMP_TO_BORDER, so every fragment reads the border and the whole viewport is one
|
|
// colour. That is deliberate: the border colour is the sampler parameter with the
|
|
// fewest other paths to the driver (it is not in the texture's own parameter set the
|
|
// way a filter effectively is), and borderColorForm is the very field G7's second
|
|
// negative control drops.
|
|
// ------------------------------------------------------------------------------------
|
|
TEST_F(HandleRecycleScenario, ASamplerAtARecycledAddressDoesNotInheritItsPredecessorsParameters) {
|
|
if (!Ready()) return;
|
|
SkipUnlessTheArmIsAssertableHere();
|
|
if (IsSkipped()) return;
|
|
SkipUnlessTheObjectHandlePathIsAssertableHere("sampler object");
|
|
if (IsSkipped()) return;
|
|
|
|
const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f);
|
|
GLuint vao = 0;
|
|
glGenVertexArrays(1, &vao);
|
|
ConfigureQuadVao(vao, buffer);
|
|
// The texture is BLUE and is never what the case reads: every fragment samples outside
|
|
// [0,1], so what comes back is the sampler's border colour and nothing else.
|
|
const GLuint texture = MakeSolidTexture(0, 0, 255);
|
|
|
|
const auto makeBorderSampler = [](float r, float g, float b) {
|
|
GLuint sampler = 0;
|
|
glGenSamplers(1, &sampler);
|
|
const float border[4] = {r, g, b, 1.0f};
|
|
glSamplerParameteri(sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
|
|
glSamplerParameterfv(sampler, GL_TEXTURE_BORDER_COLOR, border);
|
|
return sampler;
|
|
};
|
|
// Sampled at a constant UV well outside [0,1]: one colour for the whole viewport.
|
|
static const char* kBorderFS = R"(#version 330 core
|
|
uniform sampler2D uTex;
|
|
out vec4 oColor;
|
|
void main() { oColor = texture(uTex, vec2(4.0, 4.0)); }
|
|
)";
|
|
std::string error;
|
|
const GLuint borderProgram = CompileProgram(kSampleVS, kBorderFS, &error);
|
|
ASSERT_NE(borderProgram, 0u) << error;
|
|
|
|
const auto drawThroughSampler = [&](GLuint sampler) {
|
|
BindDefaultFramebuffer();
|
|
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
|
glUseProgram(borderProgram);
|
|
glUniform1i(glGetUniformLocation(borderProgram, "uTex"), 0);
|
|
glActiveTexture(GL_TEXTURE0);
|
|
glBindTexture(GL_TEXTURE_2D, texture);
|
|
glBindSampler(0, sampler);
|
|
glBindVertexArray(vao);
|
|
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
|
|
const Image image = ReadPixels(Gl().Width(), Gl().Height());
|
|
Gl().EndFrame();
|
|
return image;
|
|
};
|
|
|
|
const GLuint redSampler = makeBorderSampler(1.0f, 0.0f, 0.0f);
|
|
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first sampler left a GL error";
|
|
for (int frame = 0; frame < kWarmupFrames; ++frame) {
|
|
const Image warm = drawThroughSampler(redSampler);
|
|
ExpectWholeViewportIs(warm, "red", "sampler warm-up frame " + std::to_string(frame));
|
|
}
|
|
|
|
glBindSampler(0, 0);
|
|
GLuint doomed = redSampler;
|
|
glDeleteSamplers(1, &doomed);
|
|
|
|
const GLuint greenSampler = makeBorderSampler(0.0f, 1.0f, 0.0f);
|
|
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the replacement sampler left a GL error";
|
|
if (greenSampler != redSampler) {
|
|
glDeleteSamplers(1, &greenSampler);
|
|
glDeleteTextures(1, const_cast<GLuint*>(&texture));
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
glDeleteBuffers(1, const_cast<GLuint*>(&buffer));
|
|
glDeleteProgram(borderProgram);
|
|
GTEST_SKIP() << "inconclusive, not proven: glGenSamplers returned " << greenSampler
|
|
<< " rather than the deleted " << redSampler
|
|
<< ", so no ABA was constructed";
|
|
}
|
|
RecordProperty("recycled_sampler_name", static_cast<int>(greenSampler));
|
|
|
|
const Image image = drawThroughSampler(greenSampler);
|
|
ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/ObjectAbaExpectation("sampler object"),
|
|
image, "green", "red",
|
|
"the draw after the sampler object was recycled");
|
|
|
|
glBindSampler(0, 0);
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
GLuint cleanupSampler = greenSampler;
|
|
glDeleteSamplers(1, &cleanupSampler);
|
|
GLuint cleanupTexture = texture;
|
|
glDeleteTextures(1, &cleanupTexture);
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
GLuint cleanupBuffer = buffer;
|
|
glDeleteBuffers(1, &cleanupBuffer);
|
|
glDeleteProgram(borderProgram);
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------
|
|
// 6. The sampler VIEW (SamplerViewCso), which is a different question from either of the
|
|
// two above and is the reason it gets a case rather than a comment.
|
|
//
|
|
// A sampler view is the RESOLVED (texture, sampler) pair - what the unit actually
|
|
// samples - and P4a addresses it by IDENTITY per texture object (D-F2, the deviation
|
|
// from ARCHITECTURE.md:63's content-addressed 4096-entry cache, which is P7's). So the
|
|
// view's key names the texture; the case recycles the TEXTURE while an explicitly bound
|
|
// SAMPLER OBJECT stays alive and unchanged across the window, so what a stale view
|
|
// would inherit is the dead texture through a live sampler - which the texture case
|
|
// above cannot construct, because there the unit has no sampler object bound at all and
|
|
// the built-in sampler travels with the texture.
|
|
// ------------------------------------------------------------------------------------
|
|
TEST_F(HandleRecycleScenario, ATextureAtARecycledAddressDoesNotInheritItsPredecessorsSamplerView) {
|
|
if (!Ready()) return;
|
|
SkipUnlessTheArmIsAssertableHere();
|
|
if (IsSkipped()) return;
|
|
SkipUnlessTheObjectHandlePathIsAssertableHere("sampler view");
|
|
if (IsSkipped()) return;
|
|
|
|
const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f);
|
|
GLuint vao = 0;
|
|
glGenVertexArrays(1, &vao);
|
|
ConfigureQuadVao(vao, buffer);
|
|
|
|
// One sampler object for the whole case: it is the half of the view's identity that
|
|
// must NOT move, so that a stale view can only come from the texture half.
|
|
GLuint sampler = 0;
|
|
glGenSamplers(1, &sampler);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
|
|
|
const auto drawSampled = [&](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);
|
|
glBindSampler(0, sampler);
|
|
glBindVertexArray(vao);
|
|
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
|
|
const Image image = ReadPixels(Gl().Width(), Gl().Height());
|
|
Gl().EndFrame();
|
|
return image;
|
|
};
|
|
|
|
const GLuint redTexture = MakeSolidTexture(255, 0, 0);
|
|
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first texture left a GL error";
|
|
for (int frame = 0; frame < kWarmupFrames; ++frame) {
|
|
const Image warm = drawSampled(redTexture);
|
|
ExpectWholeViewportIs(warm, "red", "sampler-view warm-up frame " + std::to_string(frame));
|
|
}
|
|
|
|
glBindSampler(0, 0);
|
|
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) {
|
|
GLuint cleanup = greenTexture;
|
|
glDeleteTextures(1, &cleanup);
|
|
glDeleteSamplers(1, &sampler);
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
glDeleteBuffers(1, const_cast<GLuint*>(&buffer));
|
|
GTEST_SKIP() << "inconclusive, not proven: glGenTextures returned " << greenTexture
|
|
<< " rather than the deleted " << redTexture
|
|
<< ", so no ABA was constructed for the sampler view";
|
|
}
|
|
RecordProperty("recycled_sampler_view_texture_name", static_cast<int>(greenTexture));
|
|
|
|
const Image image = drawSampled(greenTexture);
|
|
ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/ObjectAbaExpectation("sampler view"),
|
|
image, "green", "red",
|
|
"the draw after the sampler view's texture was recycled under a live "
|
|
"sampler object");
|
|
|
|
glBindSampler(0, 0);
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
GLuint cleanupTexture = greenTexture;
|
|
glDeleteTextures(1, &cleanupTexture);
|
|
glDeleteSamplers(1, &sampler);
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
GLuint cleanupBuffer = buffer;
|
|
glDeleteBuffers(1, &cleanupBuffer);
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------
|
|
// 7. The program (ShaderCso). Espryt reaches a program's backend twin through
|
|
// g_programTwinLookupMemo (DirectGLES.cpp:134-135), which is keyed on the frontend
|
|
// ProgramObject; P4a re-keys it on the shader CSO's {slot, gen} and gives the program a
|
|
// create_shader_state / delete_shader_state lifetime of its own.
|
|
//
|
|
// A program's colour is BAKED INTO ITS SOURCE here rather than passed as a uniform, so
|
|
// "the draw used the dead program" is the only thing the pixels can mean: a uniform
|
|
// would be re-set on the replacement and would hide exactly the inheritance the case is
|
|
// about.
|
|
// ------------------------------------------------------------------------------------
|
|
TEST_F(HandleRecycleScenario, AProgramAtARecycledAddressDoesNotInheritItsPredecessorsShaderCso) {
|
|
if (!Ready()) return;
|
|
SkipUnlessTheArmIsAssertableHere();
|
|
if (IsSkipped()) return;
|
|
SkipUnlessTheObjectHandlePathIsAssertableHere("program");
|
|
if (IsSkipped()) return;
|
|
|
|
static const char* kRedFS = R"(#version 330 core
|
|
out vec4 oColor;
|
|
void main() { oColor = vec4(1.0, 0.0, 0.0, 1.0); }
|
|
)";
|
|
static const char* kGreenFS = R"(#version 330 core
|
|
out vec4 oColor;
|
|
void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); }
|
|
)";
|
|
const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f);
|
|
GLuint vao = 0;
|
|
glGenVertexArrays(1, &vao);
|
|
ConfigureQuadVao(vao, buffer);
|
|
|
|
const auto drawWith = [&](GLuint program) {
|
|
BindDefaultFramebuffer();
|
|
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
|
glUseProgram(program);
|
|
glBindVertexArray(vao);
|
|
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
|
|
const Image image = ReadPixels(Gl().Width(), Gl().Height());
|
|
Gl().EndFrame();
|
|
return image;
|
|
};
|
|
|
|
std::string error;
|
|
const GLuint redProgram = CompileProgram(kColorVS, kRedFS, &error);
|
|
ASSERT_NE(redProgram, 0u) << error;
|
|
for (int frame = 0; frame < kWarmupFrames; ++frame) {
|
|
const Image warm = drawWith(redProgram);
|
|
ExpectWholeViewportIs(warm, "red", "program warm-up frame " + std::to_string(frame));
|
|
}
|
|
|
|
// Unbind first: a program that is still current is kept alive by the frontend, so
|
|
// glDeleteProgram would only flag it and the object would not die here.
|
|
glUseProgram(0);
|
|
GLuint doomed = redProgram;
|
|
glDeleteProgram(doomed);
|
|
|
|
const GLuint greenProgram = CompileProgram(kColorVS, kGreenFS, &error);
|
|
ASSERT_NE(greenProgram, 0u) << error;
|
|
if (greenProgram != redProgram) {
|
|
glDeleteProgram(greenProgram);
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
glDeleteBuffers(1, const_cast<GLuint*>(&buffer));
|
|
GTEST_SKIP() << "inconclusive, not proven: glCreateProgram returned " << greenProgram
|
|
<< " rather than the deleted " << redProgram
|
|
<< ", so no ABA was constructed";
|
|
}
|
|
RecordProperty("recycled_program_name", static_cast<int>(greenProgram));
|
|
|
|
const Image image = drawWith(greenProgram);
|
|
ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/ObjectAbaExpectation("program"), image,
|
|
"green", "red", "the draw after the program was recycled");
|
|
|
|
glUseProgram(0);
|
|
glDeleteProgram(greenProgram);
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
GLuint cleanupBuffer = buffer;
|
|
glDeleteBuffers(1, &cleanupBuffer);
|
|
}
|
|
|
|
// ====================================================================================
|
|
// G8b: one leak case per kind P4a mints. See AssertChurnReturnsEverySlot for the shape and
|
|
// for why a kind that was never minted SKIPS rather than passing.
|
|
// ====================================================================================
|
|
|
|
TEST_F(HandleRecycleScenario, DestroyedTexturesReturnTheirTextureSlots) {
|
|
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);
|
|
|
|
AssertChurnReturnsEverySlot(
|
|
PipeSlotKind::Texture, SlotSpace::Ordinary, "Texture", "B (clientfb)",
|
|
/*maxInFlight=*/1u,
|
|
[&](bool checkPixels, const std::function<void()>& observe) {
|
|
const GLuint texture = MakeSolidTexture(0, 255, 0);
|
|
const Image image = DrawTexturedQuadAndRead(vao, texture);
|
|
if (checkPixels) {
|
|
// One picture check, so a green here cannot mean "the draws never happened
|
|
// and therefore nothing was ever minted".
|
|
ExpectWholeViewportIs(image, "green", "the first churned texture's draw");
|
|
}
|
|
observe();
|
|
glActiveTexture(GL_TEXTURE0);
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
GLuint doomed = texture;
|
|
glDeleteTextures(1, &doomed);
|
|
});
|
|
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
GLuint cleanupBuffer = buffer;
|
|
glDeleteBuffers(1, &cleanupBuffer);
|
|
}
|
|
|
|
TEST_F(HandleRecycleScenario, DestroyedTexturesReturnTheirSamplerViewSlots) {
|
|
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);
|
|
|
|
// A sampler view is identity-addressed PER TEXTURE OBJECT (D-F2), so the churn that
|
|
// exercises it is a texture churn - and the view's slot is released by the TEXTURE's
|
|
// death helper (D-I1: the texture helper emits ResourceDestroy, DeleteSamplerView and
|
|
// DeleteSamplerState), which is precisely why it needs a case of its own: a helper
|
|
// that forgot one of its three frees leaks only that kind and nothing else moves.
|
|
AssertChurnReturnsEverySlot(
|
|
PipeSlotKind::SamplerViewCso, SlotSpace::Ordinary, "SamplerViewCso", "C (clientsp)",
|
|
/*maxInFlight=*/1u,
|
|
[&](bool checkPixels, const std::function<void()>& observe) {
|
|
const GLuint texture = MakeSolidTexture(0, 255, 0);
|
|
const Image image = DrawTexturedQuadAndRead(vao, texture);
|
|
if (checkPixels) {
|
|
ExpectWholeViewportIs(image, "green", "the first churned sampler view's draw");
|
|
}
|
|
observe();
|
|
glActiveTexture(GL_TEXTURE0);
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
GLuint doomed = texture;
|
|
glDeleteTextures(1, &doomed);
|
|
});
|
|
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
GLuint cleanupBuffer = buffer;
|
|
glDeleteBuffers(1, &cleanupBuffer);
|
|
}
|
|
|
|
TEST_F(HandleRecycleScenario, DestroyedRenderbuffersReturnTheirRenderbufferSlots) {
|
|
if (!Ready()) return;
|
|
SkipUnlessTheArmIsAssertableHere();
|
|
if (IsSkipped()) return;
|
|
|
|
GLuint fbo = 0;
|
|
glGenFramebuffers(1, &fbo);
|
|
|
|
AssertChurnReturnsEverySlot(
|
|
PipeSlotKind::Renderbuffer, SlotSpace::Ordinary, "Renderbuffer", "B (clientfb)",
|
|
/*maxInFlight=*/1u,
|
|
[&](bool checkPixels, const std::function<void()>& observe) {
|
|
GLuint renderbuffer = 0;
|
|
glGenRenderbuffers(1, &renderbuffer);
|
|
glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer);
|
|
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 4, 4);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
|
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER,
|
|
renderbuffer);
|
|
glViewport(0, 0, 4, 4);
|
|
ClearTo(0.0f, 1.0f, 0.0f, 1.0f);
|
|
if (checkPixels) {
|
|
const Image image = ReadPixels(4, 4);
|
|
ExpectWholeViewportIs(image, "green", "the first churned renderbuffer's clear");
|
|
}
|
|
observe();
|
|
// Detach before deleting: an attached renderbuffer is kept alive by the
|
|
// frontend attachment's SharedPtr and would not die inside the round.
|
|
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, 0);
|
|
BindDefaultFramebuffer();
|
|
Gl().EndFrame();
|
|
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
|
glDeleteRenderbuffers(1, &renderbuffer);
|
|
});
|
|
|
|
glDeleteFramebuffers(1, &fbo);
|
|
}
|
|
|
|
TEST_F(HandleRecycleScenario, DestroyedFramebuffersReturnTheirFramebufferSlots) {
|
|
if (!Ready()) return;
|
|
SkipUnlessTheArmIsAssertableHere();
|
|
if (IsSkipped()) return;
|
|
|
|
// One attachment for the whole case: only the framebuffer churns, so a texture slot
|
|
// that failed to come back cannot be scored against this kind.
|
|
GLuint attachment = 0;
|
|
glGenTextures(1, &attachment);
|
|
glBindTexture(GL_TEXTURE_2D, attachment);
|
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
|
|
// A FRAMEBUFFER HAS NO WIRE LIFETIME AT ALL (D-I2): no create_*, no destroy row, and a
|
|
// death helper that raises the notice and frees the slot and emits nothing. The
|
|
// allocator is therefore the only observable this kind has, which makes this case the
|
|
// whole of its lifetime coverage rather than a supplement to a wire assertion.
|
|
AssertChurnReturnsEverySlot(
|
|
PipeSlotKind::Framebuffer, SlotSpace::Ordinary, "Framebuffer", "B (clientfb)",
|
|
/*maxInFlight=*/1u,
|
|
[&](bool checkPixels, const std::function<void()>& observe) {
|
|
GLuint fbo = 0;
|
|
glGenFramebuffers(1, &fbo);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
|
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
|
|
attachment, 0);
|
|
glViewport(0, 0, 4, 4);
|
|
ClearTo(0.0f, 1.0f, 0.0f, 1.0f);
|
|
if (checkPixels) {
|
|
const Image image = ReadPixels(4, 4);
|
|
ExpectWholeViewportIs(image, "green", "the first churned framebuffer's clear");
|
|
}
|
|
observe();
|
|
BindDefaultFramebuffer();
|
|
Gl().EndFrame();
|
|
glDeleteFramebuffers(1, &fbo);
|
|
});
|
|
|
|
glDeleteTextures(1, &attachment);
|
|
}
|
|
|
|
TEST_F(HandleRecycleScenario, DestroyedSamplersReturnTheirSamplerCsoSlots) {
|
|
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 texture = MakeSolidTexture(0, 255, 0);
|
|
|
|
// EVERY ROUND'S SAMPLER CARRIES A DIFFERENT PARAMETER SET, and that is not decoration:
|
|
// sampler CSOs are CONTENT-addressed at capacity 256 (D-F1), so 48 identical samplers
|
|
// would legitimately be ONE CSO and the case would assert nothing about the death
|
|
// path. The LOD bias moves per round, which changes the hash and nothing else.
|
|
//
|
|
// ...AND THE SET IS FINITE ON PURPOSE, which is the other half of the same argument.
|
|
// The bias cycles through kDistinctSamplerContents values, so the churn walks a
|
|
// CLOSED content space; the warm-up below walks all of it once before the baseline is
|
|
// taken, and every measured round then asks the cache for an entry it already holds.
|
|
// Without that, the case measured the cache filling up - one retained slot per
|
|
// distinct parameter block, which is C's design (ID-17) and not a death-path defect -
|
|
// and reported it as P3a's C-1 leak. Measured on the tree where C landed: 48 rounds,
|
|
// 14 slots retained, i.e. the distinct contents minus the two the warm-up had already
|
|
// interned. With the whole space warm the same churn must move nothing at all.
|
|
constexpr int kDistinctSamplerContents = 16;
|
|
int round = 0;
|
|
AssertChurnReturnsEverySlot(
|
|
PipeSlotKind::SamplerCso, SlotSpace::Ordinary, "SamplerCso", "C (clientsp)",
|
|
/*maxInFlight=*/1u,
|
|
[&](bool checkPixels, const std::function<void()>& observe) {
|
|
GLuint sampler = 0;
|
|
glGenSamplers(1, &sampler);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
|
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
|
glSamplerParameterf(sampler, GL_TEXTURE_MIN_LOD,
|
|
-static_cast<float>(round % 16));
|
|
++round;
|
|
|
|
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);
|
|
glBindSampler(0, sampler);
|
|
glBindVertexArray(vao);
|
|
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
|
|
const Image image = ReadPixels(Gl().Width(), Gl().Height());
|
|
Gl().EndFrame();
|
|
if (checkPixels) {
|
|
ExpectWholeViewportIs(image, "green", "the first churned sampler's draw");
|
|
}
|
|
observe();
|
|
glBindSampler(0, 0);
|
|
glDeleteSamplers(1, &sampler);
|
|
},
|
|
/*warmUpRounds=*/static_cast<unsigned>(kDistinctSamplerContents) + 2u);
|
|
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
GLuint cleanupTexture = texture;
|
|
glDeleteTextures(1, &cleanupTexture);
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
GLuint cleanupBuffer = buffer;
|
|
glDeleteBuffers(1, &cleanupBuffer);
|
|
}
|
|
|
|
TEST_F(HandleRecycleScenario, DestroyedProgramsReturnTheirShaderCsoSlots) {
|
|
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);
|
|
|
|
// A DIFFERENT SOURCE PER ROUND, for D-F1's reason applied to programs: a shader CSO is
|
|
// keyed on (ShaderCso, Version) and a program archive is content-addressed, so 48
|
|
// identical programs could legitimately be one CSO. The constant in the fragment
|
|
// shader moves per round.
|
|
int round = 0;
|
|
AssertChurnReturnsEverySlot(
|
|
PipeSlotKind::ShaderCso, SlotSpace::Ordinary, "ShaderCso", "C (clientsp)",
|
|
/*maxInFlight=*/1u,
|
|
[&](bool checkPixels, const std::function<void()>& observe) {
|
|
const std::string fs = "#version 330 core\nout vec4 oColor;\nvoid main() { "
|
|
"oColor = vec4(0.0, 1.0, 0.0, 1.0) + vec4(" +
|
|
std::to_string(round) + ".0 * 0.0); }\n";
|
|
++round;
|
|
std::string error;
|
|
const GLuint program = CompileProgram(kColorVS, fs.c_str(), &error);
|
|
ASSERT_NE(program, 0u) << error;
|
|
BindDefaultFramebuffer();
|
|
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
|
glUseProgram(program);
|
|
glBindVertexArray(vao);
|
|
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
|
|
const Image image = ReadPixels(Gl().Width(), Gl().Height());
|
|
Gl().EndFrame();
|
|
if (checkPixels) {
|
|
ExpectWholeViewportIs(image, "green", "the first churned program's draw");
|
|
}
|
|
observe();
|
|
glUseProgram(0);
|
|
glDeleteProgram(program);
|
|
});
|
|
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
GLuint cleanupBuffer = buffer;
|
|
glDeleteBuffers(1, &cleanupBuffer);
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------
|
|
// The COMPOSITE's own leak case (G8b, D-H7). It is a separate case and not a second phase
|
|
// of the one above for the reason the band exists at all: a composite's ShaderCso slot
|
|
// comes out of the reserved high band (kMGPipeShaderCsoCompositeSlotBase = 983040) through
|
|
// the allocator's ONE door into it, AllocateComposite, and it is released by TWO
|
|
// independent paths - ProgramPipelineObject::GetCachedDrawProgram's LRU eviction and the
|
|
// composite ProgramObject's own destructor. The second free is a no-op only because Free
|
|
// refuses a slot that is not live at that generation (SlotAllocator.cpp:117-119); if it
|
|
// ever stopped being one, this is where a double free or a never-freed band slot shows up,
|
|
// and nowhere else - the band is sparse against the ordinary program space by design, so
|
|
// an ordinary program's leak case cannot see it.
|
|
// ------------------------------------------------------------------------------------
|
|
TEST_F(HandleRecycleScenario, EvictedPipelineCompositesReturnTheirShaderCsoSlots) {
|
|
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);
|
|
|
|
static const char* kSeparableVS = R"(#version 410 core
|
|
in vec2 aPos;
|
|
in vec3 aColor;
|
|
out gl_PerVertex { vec4 gl_Position; };
|
|
out vec3 vColor;
|
|
void main() {
|
|
vColor = aColor;
|
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
|
}
|
|
)";
|
|
int round = 0;
|
|
AssertChurnReturnsEverySlot(
|
|
PipeSlotKind::ShaderCso,
|
|
// THE BAND'S OWN COUNTERS, not the ordinary ShaderCso space's (review F-M4,
|
|
// contract-v2.md 4.3/7.6). A round creates THREE ShaderCsos - the two stage
|
|
// programs and the composite the pipeline flattens them into - and the two stage
|
|
// programs are ORDINARY slots. So the ordinary space moves in this case whether
|
|
// or not a composite ever comes back, the "nothing was ever minted" skip would
|
|
// not fire, and every assertion below would have been a statement about the two
|
|
// stage programs while the band - the double-free-refusal case the band exists to
|
|
// police - went unread.
|
|
SlotSpace::CompositeBand, "ShaderCso (pipeline composites)", "C (clientsp)",
|
|
// ONE in flight, because in the BAND a round holds exactly one slot: the
|
|
// composite. (Against the ordinary space the answer would have been three.)
|
|
/*maxInFlight=*/1u,
|
|
[&](bool checkPixels, const std::function<void()>& observe) {
|
|
// A DIFFERENT FRAGMENT STAGE PER ROUND: the composite is keyed on
|
|
// ProgramPipelineObject::ComputeDrawProgramSignature(), the per-stage
|
|
// {lifetimeId, GetLinkVersion()} array, so a round that rebuilt an identical
|
|
// pipeline out of the same two programs would legitimately reuse one composite
|
|
// and the case would assert nothing about the release paths.
|
|
const std::string fsSource =
|
|
"#version 410 core\nin vec3 vColor;\nout vec4 oColor;\nvoid main() { oColor "
|
|
"= vec4(vColor, 1.0) + vec4(" + std::to_string(round) + ".0 * 0.0); }\n";
|
|
++round;
|
|
const char* vsSource = kSeparableVS;
|
|
const char* fsSourcePtr = fsSource.c_str();
|
|
const GLuint vs = glCreateShaderProgramv(GL_VERTEX_SHADER, 1, &vsSource);
|
|
const GLuint fs = glCreateShaderProgramv(GL_FRAGMENT_SHADER, 1, &fsSourcePtr);
|
|
GLuint pipeline = 0;
|
|
glGenProgramPipelines(1, &pipeline);
|
|
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
|
|
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
|
|
|
|
BindDefaultFramebuffer();
|
|
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
|
glUseProgram(0);
|
|
glBindProgramPipeline(pipeline);
|
|
glBindVertexArray(vao);
|
|
glDrawArrays(GL_TRIANGLES, 0, kVertexCount);
|
|
const Image image = ReadPixels(Gl().Width(), Gl().Height());
|
|
Gl().EndFrame();
|
|
if (checkPixels) {
|
|
// The draw has to actually happen: GetProgramForDraw() flattens the
|
|
// pipeline into a composite at the validate point and nowhere else, so a
|
|
// round whose draw was dropped mints no composite and the case would be
|
|
// measuring an empty churn.
|
|
ExpectWholeViewportIs(image, "white", "the first churned composite's draw");
|
|
}
|
|
observe();
|
|
glBindProgramPipeline(0);
|
|
glDeleteProgramPipelines(1, &pipeline);
|
|
glDeleteProgram(vs);
|
|
glDeleteProgram(fs);
|
|
});
|
|
|
|
glBindVertexArray(0);
|
|
glDeleteVertexArrays(1, &vao);
|
|
GLuint cleanupBuffer = buffer;
|
|
glDeleteBuffers(1, &cleanupBuffer);
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace MGITest
|