155 lines
6.3 KiB
C++
155 lines
6.3 KiB
C++
//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;
|
|
}
|