Crafter.Graphics/tests/DeferredDeletion/main.cpp

155 lines
6.3 KiB
C++
Raw Normal View History

2026-07-22 18:09:06 +02:00
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Issue #101: fence-keyed deferred resource-deletion queue. Since #40 dropped
// the per-frame vkQueueWaitIdle, a buffer the CPU is done with may still be
// read by the GPU for up to framesInFlight-1 more frames — so destroying it
// immediately (VulkanBuffer::Clear / the old Resize path) is a use-after-free.
// VulkanBuffer::DeferredClear() / Resize() now hand the handles to Device's
// deletion queue, tagged with the current frameCounter; ReclaimDeletions frees
// an entry only once framesInFlight frames have elapsed, and DrainDeletions
// (called after a wait-idle) frees everything unconditionally.
//
// The retire-timing is the load-bearing logic: free too early and it's the
// very UAF the queue exists to prevent; free too late and resources leak. This
// test drives it on a real headless Vulkan device (real VkBuffer/VkDeviceMemory
// so the frees actually execute and the validation layer can object), manually
// stepping Device::frameCounter to pin down the exact frame an entry is
// reclaimed on. It needs no swapchain/window — the queue itself is window-free.
#include "vulkan/vulkan.h"
#include <cstdlib>
import Crafter.Graphics;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
constexpr VkBufferUsageFlags2 USAGE =
VK_BUFFER_USAGE_2_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_2_SHADER_DEVICE_ADDRESS_BIT;
constexpr auto DEVICE_LOCAL = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT;
} // namespace
int main() {
Device::Initialize();
// No Window here, so set the in-flight depth ourselves (Window::Render
// normally does this at init). Use the real window depth so the timing
// matches production.
Device::framesInFlight = Window::numFrames;
Device::frameCounter = 0;
Device::deletionQueue.clear();
// ── DeferredClear enqueues, nulls the handle, and retires after exactly
// framesInFlight frames ──────────────────────────────────────────────
{
VulkanBuffer<float, false> buf;
buf.Create(USAGE, DEVICE_LOCAL, 16);
Check(buf.buffer != VK_NULL_HANDLE, "Create produced a real buffer handle");
buf.DeferredClear();
Check(buf.buffer == VK_NULL_HANDLE,
"DeferredClear nulls the handle so the buffer no longer owns it");
Check(Device::deletionQueue.size() == 1,
"DeferredClear enqueues exactly one pending deletion");
// Walk right up to the retire frame: still queued at every frame before
// frameCounter reaches retireAfter + framesInFlight.
for (std::uint64_t f = 0; f < Window::numFrames; ++f) {
Device::frameCounter = f;
Device::ReclaimDeletions();
Check(Device::deletionQueue.size() == 1,
std::format("entry survives ReclaimDeletions at frame {} "
"(< retire frame {})", f, Window::numFrames));
}
// At retireAfter(0) + framesInFlight it is finally freed.
Device::frameCounter = Window::numFrames;
Device::ReclaimDeletions();
Check(Device::deletionQueue.empty(),
std::format("entry is reclaimed once frameCounter reaches {}",
Window::numFrames));
}
// ── Resize's reallocate path defers the OLD allocation, not the new one ──
{
Device::frameCounter = 100;
Device::deletionQueue.clear();
VulkanBuffer<float, false> buf;
buf.Create(USAGE, DEVICE_LOCAL, 4);
VkBuffer original = buf.buffer;
// Grow past capacity → forces destroy+recreate, which must DEFER the old
// buffer (the #63 UAF this whole queue exists to close).
buf.Resize(USAGE, DEVICE_LOCAL, 64);
Check(buf.buffer != VK_NULL_HANDLE && buf.buffer != original,
"Resize past capacity allocates a fresh buffer");
Check(Device::deletionQueue.size() == 1
&& Device::deletionQueue.front().buffer == original,
"Resize defers the OLD allocation to the deletion queue");
Check(Device::deletionQueue.front().retireAfter == 100,
"deferred entry is tagged with the frameCounter at enqueue time");
// Not yet due (tagged at 100, retires at 100 + framesInFlight).
Device::frameCounter = 100 + Window::numFrames - 1;
Device::ReclaimDeletions();
Check(Device::deletionQueue.size() == 1,
"old allocation is not freed before its retire frame");
Device::frameCounter = 100 + Window::numFrames;
Device::ReclaimDeletions();
Check(Device::deletionQueue.empty(),
"old allocation is freed on its retire frame");
buf.Clear(); // immediate cleanup of the live buffer (no GPU work pending)
}
// ── DrainDeletions frees everything regardless of retire frame ───────────
{
Device::frameCounter = 0;
Device::deletionQueue.clear();
VulkanBuffer<float, false> a;
VulkanBuffer<float, false> b;
a.Create(USAGE, DEVICE_LOCAL, 8);
b.Create(USAGE, DEVICE_LOCAL, 8);
a.DeferredClear();
b.DeferredClear();
Check(Device::deletionQueue.size() == 2, "two entries queued");
// Far from any retire frame — ReclaimDeletions would free nothing here.
Device::DrainDeletions();
Check(Device::deletionQueue.empty(),
"DrainDeletions frees every queued entry unconditionally");
}
// ── EnqueueDeletion ignores a null handle (nothing to free) ──────────────
{
Device::deletionQueue.clear();
Device::EnqueueDeletion(VK_NULL_HANDLE, VK_NULL_HANDLE);
Check(Device::deletionQueue.empty(),
"EnqueueDeletion drops a VK_NULL_HANDLE buffer");
}
Check(Device::validationErrorCount == 0,
std::format("no Vulkan validation errors across the run ({} seen)",
Device::validationErrorCount));
if (failures != 0) {
std::println("{} check(s) failed", failures);
return EXIT_FAILURE;
}
std::println("all checks passed");
return EXIT_SUCCESS;
}