//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 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 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 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 a; VulkanBuffer 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; }