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

Merged
catbot merged 1 commit from claude/issue-101 into master 2026-06-17 15:21:47 +02:00
Member

Problem

Since #40 (per-frame fences, no per-frame vkQueueWaitIdle) landed, frames are pipelined: a resource the CPU is "done" with may still be read by the GPU for up to numFrames-1 more frames. But VulkanBuffer::Clear()/Resize() destroyed + freed immediately, making Resize's destroy-and-recreate path a live use-after-free (#63) whenever an in-flight frame still referenced the old buffer.

Fix — fence-keyed deferred deletion queue

Device gains a monotonic, frame-counter-keyed deletion queue:

  • EnqueueDeletion(buffer, memory) tags the handles with the current frameCounter.
  • ReclaimDeletions() frees entries where retireAfter + framesInFlight <= frameCounter — called once per frame after the per-image fence wait.
  • DrainDeletions() frees everything unconditionally — called after a wait-idle.
  • VulkanBuffer::DeferredClear() hands {buffer, memory} to the queue and nulls the handle; Resize() uses it instead of immediate Clear(), closing #63.

Window::Render sets framesInFlight at init, reclaims after the fence wait, bumps Device::frameCounter once per frame, and drains on the resize / OUT_OF_DATE wait-idle paths.

Decisions (per the issue)

  • Destructor stays immediate (~VulkanBuffer keeps Clear()) — callers destroying objects mid-flight remain responsible, unchanged from today.
  • Monotonic keying (not per-slot buckets) so Mesh/Device code can enqueue too, not just the window loop.
  • The one-shot immediate submits that still vkQueueWaitIdle are synchronous and keep immediate Clear().

Correctness

An entry tagged at frame F is freed only once framesInFlight frames have elapsed (each gated by a per-image fence wait); single-queue submission order then guarantees all <=F GPU work is complete.

Tests

New DeferredDeletion test drives the retire timing on a real headless Vulkan device (real VkBuffer/VkDeviceMemory so the frees actually execute and the validation layer can object), stepping Device::frameCounter to pin the exact reclaim frame, and verifying Resize defers the old allocation, DrainDeletions frees regardless of retire frame, and validation stays silent. Full suite: 19 passed (incl. FrameLoopSync).

Resolves #101.

🤖 Generated with Claude Code

## Problem Since #40 (per-frame fences, no per-frame `vkQueueWaitIdle`) landed, frames are pipelined: a resource the CPU is "done" with may still be read by the GPU for up to `numFrames-1` more frames. But `VulkanBuffer::Clear()`/`Resize()` destroyed + freed **immediately**, making `Resize`'s destroy-and-recreate path a live use-after-free (#63) whenever an in-flight frame still referenced the old buffer. ## Fix — fence-keyed deferred deletion queue `Device` gains a monotonic, frame-counter-keyed deletion queue: - `EnqueueDeletion(buffer, memory)` tags the handles with the current `frameCounter`. - `ReclaimDeletions()` frees entries where `retireAfter + framesInFlight <= frameCounter` — called once per frame after the per-image fence wait. - `DrainDeletions()` frees everything unconditionally — called after a wait-idle. - `VulkanBuffer::DeferredClear()` hands `{buffer, memory}` to the queue and nulls the handle; `Resize()` uses it instead of immediate `Clear()`, closing #63. `Window::Render` sets `framesInFlight` at init, reclaims after the fence wait, bumps `Device::frameCounter` once per frame, and drains on the resize / `OUT_OF_DATE` wait-idle paths. ### Decisions (per the issue) - **Destructor stays immediate** (`~VulkanBuffer` keeps `Clear()`) — callers destroying objects mid-flight remain responsible, unchanged from today. - Monotonic keying (not per-slot buckets) so Mesh/Device code can enqueue too, not just the window loop. - The one-shot immediate submits that still `vkQueueWaitIdle` are synchronous and keep immediate `Clear()`. ### Correctness An entry tagged at frame F is freed only once `framesInFlight` frames have elapsed (each gated by a per-image fence wait); single-queue submission order then guarantees all `<=F` GPU work is complete. ## Tests New `DeferredDeletion` test drives the retire timing on a **real headless Vulkan device** (real `VkBuffer`/`VkDeviceMemory` so the frees actually execute and the validation layer can object), stepping `Device::frameCounter` to pin the exact reclaim frame, and verifying `Resize` defers the *old* allocation, `DrainDeletions` frees regardless of retire frame, and validation stays silent. Full suite: **19 passed** (incl. `FrameLoopSync`). Resolves #101. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
catbot merged commit 3e0a38fafb into master 2026-06-17 15:21:47 +02:00
catbot deleted branch claude/issue-101 2026-06-17 15:21:48 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Catcrafts/Crafter.Graphics!102
No description provided.