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

@ -294,6 +294,44 @@ export namespace Crafter {
inline static VkDeviceSize directWriteBudget = 0;
static void CacheUploadStrategy();
// ─── Fence-keyed deferred resource deletion (issue #101) ────────
// Since #40 dropped the per-frame vkQueueWaitIdle, frames are
// pipelined: a buffer the CPU is "done" with may still be read by the
// GPU for up to framesInFlight-1 more frames. Destroying it
// immediately (as Clear() does) is then a use-after-free. Callers that
// free a buffer which may still be in flight — VulkanBuffer::Resize's
// reallocate path, VulkanBuffer::DeferredClear — hand the handles here
// instead, tagged with the current frameCounter; ReclaimDeletions frees
// an entry only once framesInFlight frames have elapsed, by which point
// single-queue submission order guarantees all GPU work that could
// reference it has completed.
struct PendingDeletion {
std::uint64_t retireAfter; // frameCounter value at enqueue time
VkBuffer buffer;
VkDeviceMemory memory;
};
// Monotonic recorded-frame index, bumped once per Window::Render. Not
// the same as Window::frameCounter (which is per-window and also drives
// the acquire-semaphore slot) — this one is global so Mesh/Device code
// can enqueue too, not just the window loop.
inline static std::uint64_t frameCounter = 0;
// = Window::numFrames; set at window init. The number of frames that
// must elapse after an enqueue before the resource is safe to free.
// Zero until set, which only makes ReclaimDeletions more eager — and it
// is never called before a Window (which sets this) starts rendering.
inline static std::uint8_t framesInFlight = 0;
inline static std::vector<PendingDeletion> deletionQueue;
// Tag {buffer, memory} for deletion after framesInFlight more frames.
// A VK_NULL_HANDLE buffer is ignored (nothing to free).
static void EnqueueDeletion(VkBuffer buffer, VkDeviceMemory memory);
// Free every entry whose retire frame has been reached
// (retireAfter + framesInFlight <= frameCounter). Call once per frame
// after waiting that frame's fence.
static void ReclaimDeletions();
// Free every queued entry unconditionally. Call only after a wait-idle
// (resize / teardown), when no in-flight GPU work can reference them.
static void DrainDeletions();
// ─── Wayland key repeat ────────────────────────────────────────
// TickKeyRepeats fires onRawKeyDown / onRawKeyHold / onTextInput on
// the focused window for whichever key is currently repeating.