fix(device): fence-keyed deferred resource-deletion queue (#101)

Since #40 dropped the per-frame vkQueueWaitIdle, frames are pipelined:
a resource the CPU is done with may still be read by the GPU for up to
numFrames-1 more frames. VulkanBuffer::Resize's destroy-and-recreate
path was therefore a live use-after-free (#63), no longer masked by the
wait-idle.

Device gains a monotonic, frame-counter-keyed deletion queue:
EnqueueDeletion tags {buffer, memory} with the current frameCounter;
ReclaimDeletions (called per frame after the fence wait) frees entries
once framesInFlight frames have elapsed; DrainDeletions frees everything
after a wait-idle. VulkanBuffer::DeferredClear hands handles to the queue
and nulls the handle, and Resize uses it instead of immediate Clear().

Window::Render sets framesInFlight at init, reclaims after the per-image
fence wait, bumps Device::frameCounter once per frame, and drains on the
resize / OUT_OF_DATE wait-idle paths. The destructor keeps immediate
Clear() (callers destroying mid-flight remain responsible, unchanged).

Adds the DeferredDeletion test: drives the retire timing on a real
headless device with real buffers, stepping Device::frameCounter to pin
the exact reclaim frame, asserting validation stays silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-17 13:21:12 +00:00
commit cb012d7068
6 changed files with 312 additions and 1 deletions

View file

@ -155,6 +155,18 @@ namespace Crafter {
buffer = VK_NULL_HANDLE;
}
// Like Clear(), but hands the destroy+free to Device's fence-keyed
// deletion queue instead of doing it immediately. Use when the buffer
// may still be read by an in-flight frame's GPU work — destroying it
// now would be a use-after-free, since frames are pipelined up to
// framesInFlight deep (issue #101). The handle is nulled immediately so
// this VulkanBuffer no longer owns it; vkFreeMemory (deferred) implicitly
// unmaps mapped memory, so no explicit vkUnmapMemory is needed here.
void DeferredClear() {
Device::EnqueueDeletion(buffer, memory);
buffer = VK_NULL_HANDLE;
}
void Resize(VkBufferUsageFlags2 usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, std::uint32_t count, VkMemoryPropertyFlags preferredPropertyFlags = 0) {
// Reuse the existing allocation in place when the request still fits
// and the fixed-at-create properties match: usage flags are
@ -172,8 +184,12 @@ namespace Crafter {
size = requestedSize;
return;
}
// Defer the old allocation's destruction: an in-flight frame may
// still reference it (the #63 hazard, no longer masked by a
// per-frame wait-idle since #40). DeferredClear nulls the handle,
// so the Create below starts from a clean slate.
if(buffer != VK_NULL_HANDLE) {
Clear();
DeferredClear();
}
Create(usageFlags, memoryPropertyFlags, count, preferredPropertyFlags);
}