mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-11 05:38:31 +09:00
[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.
This commit is contained in:
@@ -33,14 +33,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.IsBgra, sizeof(attr.IsBgra)));
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Divisor, sizeof(attr.Divisor)));
|
||||
|
||||
// The buffer's heap address is an identity component of the key: a freed
|
||||
// buffer's reused address can alias an old cache entry, but only under a
|
||||
// byte-identical attribute layout - and the entry payload is a pure function
|
||||
// of the hashed inputs, with the draw path re-resolving bindingBufferKeys
|
||||
// against the live VAO attribute pointers, so an aliased hit returns exactly
|
||||
// what a rebuild would. Address drift only grows the map; the OnFrameBoundary
|
||||
// aging sweep bounds that.
|
||||
const SizeT bufferKey = reinterpret_cast<SizeT>(attr.Buffer.get());
|
||||
// The bound buffer's IDENTITY is a component of the key, and it has to be the
|
||||
// buffer's never-reused lifetime id - NOT its heap address, which this used to
|
||||
// hash. An address is recycled by the allocator, so a deleted-and-recreated
|
||||
// buffer reproduces it; combined with a byte-identical attribute layout that
|
||||
// reproduces the WHOLE content hash, and the hash is what
|
||||
// TryBindResolvedVertexBindings accepts as proof that a memoised binding still
|
||||
// reads the buffer it was resolved from. It did not: a destroyed buffer's GPU
|
||||
// slice was bound for its successor's draw, which is how a transform-feedback
|
||||
// capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous
|
||||
// test's positions) instead of its own.
|
||||
// Zero for client memory (no buffer), which is a distinct identity of its own.
|
||||
const Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0;
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey)));
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
struct BackendVertexInputState {
|
||||
HashType hash = 0;
|
||||
// Hash of the resolved Vulkan vertex layout only (bindings, attributes,
|
||||
// unsupported mask) - NO buffer identities. `hash` mixes buffer heap
|
||||
// addresses so per-chunk VBOs mint a fresh identity per buffer; keying
|
||||
// pipelines on that minted one VkPipeline per chunk section for an
|
||||
// identical layout, defeating pipeline reuse and the per-draw memo.
|
||||
// Pipelines depend only on the layout, so they key on this instead.
|
||||
// unsupported mask) - NO buffer identities. `hash` mixes each bound
|
||||
// buffer's never-reused LIFETIME ID, so per-chunk VBOs mint a fresh
|
||||
// identity per buffer; keying pipelines on that minted one VkPipeline per
|
||||
// chunk section for an identical layout, defeating pipeline reuse and the
|
||||
// per-draw memo. Pipelines depend only on the layout, so they key on this
|
||||
// instead.
|
||||
HashType layoutHash = 0;
|
||||
// Frame boundary of the last cache hit; entries idle past the
|
||||
// OnFrameBoundary retirement age are evicted (CPU heap only).
|
||||
@@ -86,8 +87,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const MG_State::GLState::VertexArrayObject& vao, HashType hash);
|
||||
const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao);
|
||||
// Frame boundary hook: ages the cache and evicts entries not hit for many
|
||||
// frames. The key mixes buffer heap addresses, so buffer/VAO churn keeps
|
||||
// minting fresh keys; without eviction the map grows for the whole session.
|
||||
// frames. The key mixes each bound buffer's never-reused lifetime id, so
|
||||
// buffer/VAO churn keeps minting fresh keys - and does so by construction,
|
||||
// not by luck: a recreated buffer can no longer land back on its dead
|
||||
// predecessor's key. Without eviction the map grows for the whole session.
|
||||
// Entries hold no Vulkan handles (pipeline creation copies the descriptions)
|
||||
// and the draw path's entry reference never spans a frame boundary, so
|
||||
// eviction here needs no GPU-idle proof. Self-gated: one counter bump and
|
||||
|
||||
@@ -3201,12 +3201,17 @@ void main() {
|
||||
// carry the most entropy of a multiply.
|
||||
const Uint64 mixed = static_cast<Uint64>(reinterpret_cast<SizeT>(vao) >> 4) * 0x9E3779B97F4A7C15ull;
|
||||
const Uint32 index = static_cast<Uint32>(mixed >> 32) & (kVaoDrawMemoSlotCount - 1);
|
||||
// The address still picks the slot (it is what the caller has in hand), but it is
|
||||
// the lifetime id that decides whether the slot is THIS object's: an address on
|
||||
// its own is recycled, and a slot matched on a recycled address hands the new VAO
|
||||
// the dead one's resolved bindings.
|
||||
const Uint64 lifetimeId = vao->GetLifetimeId();
|
||||
VaoDrawMemo& first = m_vaoDrawMemoTable[index];
|
||||
if (first.vaoKey == vao) {
|
||||
if (first.vaoKey == vao && first.vaoLifetimeId == lifetimeId) {
|
||||
return &first;
|
||||
}
|
||||
VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u];
|
||||
if (second.vaoKey == vao) {
|
||||
if (second.vaoKey == vao && second.vaoLifetimeId == lifetimeId) {
|
||||
return &second;
|
||||
}
|
||||
// Miss: recycle a slot. Prefer an empty one; otherwise evict the entry whose
|
||||
@@ -3217,6 +3222,7 @@ void main() {
|
||||
victim = &second;
|
||||
}
|
||||
victim->vaoKey = vao;
|
||||
victim->vaoLifetimeId = lifetimeId;
|
||||
victim->contentHash = 0;
|
||||
victim->layoutFactsValid = false;
|
||||
// Unmatchable until a resolve completes (same rule as before: a bailed-out
|
||||
@@ -4475,9 +4481,10 @@ void main() {
|
||||
// vertex-input hash (VAO layout), render-pass hash (render targets + the draw-buffer/format
|
||||
// driven blend & write-mask gating), and the pipeline-state value hash (all fixed-function state).
|
||||
// Reset per-frame and on pipeline destruction so a memoized handle can never dangle.
|
||||
// The identity hash mixes buffer heap addresses (per-chunk VBOs mint a new
|
||||
// one per buffer); the memo and the pipeline payload key on the resolved
|
||||
// LAYOUT hash instead, so draws over identical layouts share one pipeline.
|
||||
// The identity hash mixes each bound buffer's never-reused lifetime id
|
||||
// (per-chunk VBOs mint a new one per buffer); the memo and the pipeline
|
||||
// payload key on the resolved LAYOUT hash instead, so draws over identical
|
||||
// layouts share one pipeline.
|
||||
// The one-arg fetch rides the VAO's state-pointer memo (no hash, no map).
|
||||
auto& vis = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao);
|
||||
const Uint64 vertexLayoutHash = vis.layoutHash;
|
||||
@@ -5265,7 +5272,8 @@ void main() {
|
||||
// path, re-resolving descriptors and texture layouts nothing invalidated.
|
||||
const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();
|
||||
const Bool vaoMoved =
|
||||
static_cast<const void*>(&vao) != snap.vao || vao.GetConfigVersion() != snap.vaoConfigVersion;
|
||||
static_cast<const void*>(&vao) != snap.vao || vao.GetLifetimeId() != snap.vaoLifetimeId ||
|
||||
vao.GetConfigVersion() != snap.vaoConfigVersion;
|
||||
const auto& drawFbo =
|
||||
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
|
||||
if (static_cast<const void*>(drawFbo.get()) != snap.drawFbo ||
|
||||
@@ -5334,9 +5342,11 @@ void main() {
|
||||
// VAO's content-hash memo. The hash memo shares the cache line this compare
|
||||
// chain already loaded (the config version), and the table slot is compact
|
||||
// and hot - unlike the VAO's aux-memo words, which start a second cold line
|
||||
// of every object in a VAO-cycling frame. The facts are pure functions of
|
||||
// the content hash, so a slot whose contentHash equals the live memoised
|
||||
// hash serves them for ANY VAO object, recycled addresses included.
|
||||
// of every object in a VAO-cycling frame. The slot only ever answers for
|
||||
// THIS object: LookupVaoDrawMemo matches (address, lifetime id), so a slot
|
||||
// a destroyed VAO left behind at a recycled address misses and the facts
|
||||
// are re-resolved. The contentHash compare is the second gate on top of
|
||||
// that identity check, catching a reconfiguration of the same live object.
|
||||
Uint64 auxMasks = 0;
|
||||
Bool factsKnown = false;
|
||||
Uint64 contentHash = 0;
|
||||
@@ -5515,6 +5525,7 @@ void main() {
|
||||
snap.renderStateVersion = renderStateVersion;
|
||||
snap.bindGeneration = bindGeneration;
|
||||
snap.vao = static_cast<const void*>(&vao);
|
||||
snap.vaoLifetimeId = vao.GetLifetimeId();
|
||||
snap.vaoConfigVersion = vao.GetConfigVersion();
|
||||
snap.vaoLayoutHash = vaoLayoutHash;
|
||||
snap.pipeline = pipeline;
|
||||
@@ -5933,6 +5944,7 @@ void main() {
|
||||
snap.programLifetimeId = program.GetLifetimeId();
|
||||
snap.programVersion = program.GetBackendStateVersion();
|
||||
snap.vao = &vao;
|
||||
snap.vaoLifetimeId = vao.GetLifetimeId();
|
||||
snap.vaoConfigVersion = vao.GetConfigVersion();
|
||||
snap.drawFbo = drawFbo.get();
|
||||
snap.fboVersion = drawFbo->GetObjectVersion();
|
||||
|
||||
@@ -758,6 +758,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Uint64 programLifetimeId = 0;
|
||||
Uint32 programVersion = 0;
|
||||
const void* vao = nullptr;
|
||||
// Same rule as VaoDrawMemo::vaoLifetimeId: (address, config version) is not an
|
||||
// identity, because a recycled address can arrive carrying a config version
|
||||
// the dead VAO also had (two mutations to configure one attribute is the
|
||||
// common shape), and "the VAO did not move" would then skip the layout
|
||||
// re-resolve for a different VAO.
|
||||
Uint64 vaoLifetimeId = 0;
|
||||
Uint32 vaoConfigVersion = 0;
|
||||
const void* drawFbo = nullptr;
|
||||
Uint16 fboVersion = 0;
|
||||
@@ -987,19 +993,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const MG_State::GLState::BufferObject* buffers[kMaxBindings] = {};
|
||||
Uint64 sliceEpochs[kMaxBindings] = {};
|
||||
};
|
||||
// One direct-mapped slot of the per-VAO draw-memo table below. The key is a
|
||||
// lookup hint only - a slot is never dereferenced through vaoKey; every fact it
|
||||
// carries is validated against live state before use:
|
||||
// One direct-mapped slot of the per-VAO draw-memo table below. A slot belongs to
|
||||
// the object whose (vaoKey, vaoLifetimeId) pair it carries: the address alone
|
||||
// only picks the slot, and the never-reused lifetime id is what proves the slot
|
||||
// is THIS VAO's, so the successor allocated onto a destroyed VAO's address
|
||||
// always misses. That identity check is load-bearing and the content-hash
|
||||
// validations below do NOT stand in for it - a recycled address under a
|
||||
// byte-identical configuration reproduces the content hash exactly, which is
|
||||
// how a destroyed VAO's resolved bindings were once handed to its successor's
|
||||
// draw. The slot is still never dereferenced through vaoKey, and every fact it
|
||||
// carries is still validated against live state before use:
|
||||
// - layoutHash/layoutAuxMasks are valid only while contentHash equals the LIVE
|
||||
// VAO's own hash memo (which the VAO's config version guards), so a config
|
||||
// change, a buffer rebind, or a recycled VAO address with a different
|
||||
// configuration all miss. A recycled address with a byte-identical
|
||||
// configuration AND identical bound buffers reproduces the content hash, and
|
||||
// then the facts are correct by construction (they are a pure function of it).
|
||||
// change or a buffer rebind misses even for the same object.
|
||||
// - bindings revalidates per draw exactly as before (frame serial, content
|
||||
// hash, per-binding live buffer pointers and slice epochs).
|
||||
struct alignas(64) VaoDrawMemo {
|
||||
const MG_State::GLState::VertexArrayObject* vaoKey = nullptr;
|
||||
// The VAO's never-reused lifetime id, checked alongside vaoKey. The pointer
|
||||
// ALONE is not an identity: a deleted VAO's heap address is handed straight
|
||||
// back by the next glGenVertexArrays-shaped allocation, and the successor then
|
||||
// matched this slot and inherited the dead object's memos. Both stated
|
||||
// defences failed with it, because both reduce to the content hash and the
|
||||
// content hash's buffer-identity component was itself a recycled heap address.
|
||||
Uint64 vaoLifetimeId = 0;
|
||||
// The VAO content hash (VertexInputStateFactory::GetOrComputeHash) the two
|
||||
// layout facts below were derived from; 0 while nothing valid is stored.
|
||||
Uint64 contentHash = 0;
|
||||
|
||||
Reference in New Issue
Block a user