Files
MobileGL/MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp
T
BZLZHH c6299f754f [Fix] (MG_Backend/DirectVulkan, MG_State): key per-object memos on lifetime ids, not heap addresses
A destroyed VertexArrayObject's heap address is handed straight back by the
next allocation of its size, and so is a destroyed BufferObject's. DirectVulkan
keyed its per-VAO draw memo on the VAO POINTER and folded the bound buffer's
ADDRESS into the content hash that validates the memoised bindings, so a
delete/recreate pair under a byte-identical attribute layout reproduced both
the key and its validating hash at once. The successor VAO then inherited the
dead one's resolved bindings and the draw fetched from a destroyed VkBuffer.
Both stated defences failed together, because both reduce to the content hash
and the hash's buffer-identity component was itself a recycled address.

VertexArrayObject and BufferObject now carry a globally-unique, never-reused
GetLifetimeId() - the same contract as ProgramObject's, minted from an atomic
starting at 1 so a zero-initialised slot can never name a live object.
VaoDrawMemo matches on (address, lifetime id) and stores the id on recycle,
SetupDrawSnapshot's "the VAO did not move" test compares the id alongside the
config version, and VertexInputStateFactory::ComputeHash hashes the bound
buffer's id instead of its pointer (0 for client memory).

Proven: the use-after-free reproduces at 100% incidence headless on lavapipe,
including a SEGV whose backtrace is the driver dereferencing a destroyed vertex
buffer inside lvp_queue_submit, and it is gone with the fix. New coverage -
MG_Test/State/ObjectLifetimeIdTest (deterministic, GPU-free, no context: it
waits for the real allocator to repeat an address and asserts the id differs,
and skips loudly rather than passing quietly if it never gets the chance), and
MG_IntegrationTest XfbAfterClipDistanceScenario, registered for DirectGLES,
DirectVulkan, and a third DirectVulkan run with async shader compilation pinned
on because that is a second allocation pattern. Gates: 553/553 unit green at
async=0 and async=1; the scenario 5/5 headless at both flag states; 71/72 CI
trace-replay fixtures over both backends, the one failure a pre-existing
lavapipe crash proven not a regression (identical SIGSEGV at the identical
call number under the pre-fix library).

Pending NVIDIA/X11 confirmation: the KHR-GL{32,40} transform_feedback failures
that opened this investigation never reproduced on lavapipe - the -2/-101
pre-fill signature appears in zero pre-fix runs there - so whether this clears
them is UNPROVEN and must be re-measured on the NVIDIA rig against a freshly
re-run pre-fix baseline. The residual suspect is deliberately untouched here:
m_xfbCounterSlotByObject keys its counter slot on the raw GL transform-feedback
name, so a recycled name whose generation check happens to pass would RESUME
instead of BEGIN. That path was never exercised on lavapipe and is neither
confirmed nor exonerated.
2026-08-08 23:56:53 -04:00

141 lines
6.7 KiB
C++

// MobileGL - MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// The invariant every backend memo keyed on a state object now rests on: a heap
// ADDRESS is not an identity, a lifetime id is.
//
// DirectVulkan memoises resolved vertex bindings per VertexArrayObject and folds
// the bound BufferObject's identity into the content hash that validates them.
// Both used to be heap addresses, and the allocator hands a freed address
// straight back: a VAO and a vertex buffer destroyed and immediately recreated
// under a byte-identical attribute layout reproduced BOTH the memo key and the
// validating hash, so the new draw fetched the destroyed buffer's GPU slice.
// GetLifetimeId() is what makes that impossible, so it is worth a test that
// needs no GPU, no context and no driver - only the allocator.
//
// The test does not simulate reuse; it waits for the real allocator to do it
// (which a LIFO free-list does on the very next allocation) and then asserts the
// id differs. If the allocator never repeats an address the run proves nothing,
// and the case says so with a skip rather than passing quietly.
#include <gtest/gtest.h>
#include <cstdint>
#include <memory>
#include <unordered_map>
#include "Includes.h"
#include <MG_State/GLState/BufferState/BufferObject.h>
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
using namespace MobileGL;
namespace {
// The allocation must actually happen: C++ permits eliding a new/delete pair,
// and an elided one would let two objects share an address for reasons that
// have nothing to do with the allocator - which is the only thing under test
// here. Publishing every pointer through a volatile sink keeps the pairs.
void* volatile g_addressSink = nullptr;
// Constructs and destroys `ObjectT` on the heap kAttempts times, watching for
// the allocator to hand back an address it already used. Every repeat must
// carry a lifetime id the dead occupant did not have. Returns how many repeats
// were seen, so the caller can tell "proven" from "never got the chance".
//
// Each object type has its own id counter, so a VertexArrayObject and a
// BufferObject may well both be id 1; ids are only ever compared within a
// type, which is exactly how the memos use them.
template <typename ObjectT>
int ProbeLifetimeIdAcrossAddressReuse(const char* typeName) {
constexpr int kAttempts = 64;
std::unordered_map<std::uintptr_t, Uint64> idAtAddress;
int reuseCount = 0;
Uint64 previousId = 0;
for (int attempt = 0; attempt < kAttempts; ++attempt) {
auto object = std::make_unique<ObjectT>(0u);
g_addressSink = object.get();
const auto address = reinterpret_cast<std::uintptr_t>(object.get());
const Uint64 lifetimeId = object->GetLifetimeId();
// 0 is the "this slot holds nothing" value in every memo that stores an
// id, so a live object must never be able to answer to a zeroed slot.
EXPECT_NE(lifetimeId, 0u) << typeName << " handed out lifetime id 0 (attempt " << attempt
<< "), which is the value a zero-initialised memo slot already carries";
EXPECT_GT(lifetimeId, previousId)
<< typeName << " lifetime ids must be strictly increasing, so an id is never handed out twice "
<< "(attempt " << attempt << ")";
previousId = lifetimeId;
const auto inserted = idAtAddress.emplace(address, lifetimeId);
if (!inserted.second) {
// The allocator reproduced an address: this is precisely the state in
// which a memo keyed on the address alone would hit a dead object's
// entry. The id is the thing that has to say no.
++reuseCount;
EXPECT_NE(lifetimeId, inserted.first->second)
<< typeName << " reconstructed at the address of a destroyed one reports the DEAD object's "
<< "lifetime id - a backend memo keyed on it would serve the dead object's resolved state "
<< "to this object's draws (attempt " << attempt << ")";
inserted.first->second = lifetimeId;
}
// Freed before the next construction on purpose: that ordering is what
// makes the allocator reuse the block, and it is the ordering the GL
// workload has (glDeleteVertexArrays, then the next glGenVertexArrays).
object.reset();
}
return reuseCount;
}
// Guards against a degenerate "id" that is really just the address in disguise:
// objects alive at the same time must differ too.
template <typename ObjectT>
void ExpectDistinctIdsWhileBothAlive(const char* typeName) {
auto first = std::make_unique<ObjectT>(0u);
auto second = std::make_unique<ObjectT>(0u);
g_addressSink = first.get();
g_addressSink = second.get();
EXPECT_NE(first->GetLifetimeId(), second->GetLifetimeId())
<< "two live " << typeName << "s share a lifetime id";
}
} // namespace
TEST(ObjectLifetimeIdTest, VertexArrayObjectAtARecycledAddressCarriesAFreshLifetimeId) {
using MG_State::GLState::VertexArrayObject;
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<VertexArrayObject>("VertexArrayObject");
if (reuseCount == 0) {
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
"construct/destroy rounds, so the recycled-address case was never exercised";
}
RecordProperty("address_reuses_observed", reuseCount);
}
TEST(ObjectLifetimeIdTest, BufferObjectAtARecycledAddressCarriesAFreshLifetimeId) {
using MG_State::GLState::BufferObject;
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<BufferObject>("BufferObject");
if (reuseCount == 0) {
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
"construct/destroy rounds, so the recycled-address case was never exercised";
}
RecordProperty("address_reuses_observed", reuseCount);
}
TEST(ObjectLifetimeIdTest, LiveVertexArrayObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::VertexArrayObject>("VertexArrayObject");
}
TEST(ObjectLifetimeIdTest, LiveBufferObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::BufferObject>("BufferObject");
}