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

@ -586,6 +586,37 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
rc.GetInterfacesAndImplementations(ifaces, resizeImpls);
cfg.tests.push_back(std::move(resizeTest));
// Issue #101: fence-keyed deferred resource-deletion queue. Since #40
// dropped the per-frame wait-idle, destroying a buffer the GPU may
// still read (Resize's reallocate path) is a use-after-free.
// VulkanBuffer::DeferredClear / Resize now hand handles to Device's
// queue, which ReclaimDeletions frees only after framesInFlight frames
// and DrainDeletions frees on a wait-idle. The retire timing is driven
// on a real headless device (real buffers so the frees execute and the
// validation layer can object) by stepping Device::frameCounter — no
// swapchain/window needed, so it shares the native build settings.
Test deferredTest;
Configuration& dc = deferredTest.config;
dc.path = cfg.path;
dc.name = "DeferredDeletion";
dc.outputName = "DeferredDeletion";
dc.type = ConfigurationType::Executable;
dc.target = cfg.target;
dc.march = cfg.march;
dc.mtune = cfg.mtune;
dc.debug = cfg.debug;
dc.sysroot = cfg.sysroot;
dc.dependencies = cfg.dependencies;
dc.externalDependencies = cfg.externalDependencies;
dc.compileFlags = cfg.compileFlags;
dc.linkFlags = cfg.linkFlags;
dc.defines = cfg.defines;
dc.cFiles = cfg.cFiles;
std::vector<fs::path> deferredImpls(impls.begin(), impls.end());
deferredImpls.emplace_back("tests/DeferredDeletion/main");
dc.GetInterfacesAndImplementations(ifaces, deferredImpls);
cfg.tests.push_back(std::move(deferredTest));
// Issue #89: Device::PreferDirectDeviceWrite chooses the upload strategy
// for a CPU-written, GPU-read buffer — direct HOST_VISIBLE|DEVICE_LOCAL
// map+write on ReBAR/UMA vs. staged-into-pure-DEVICE_LOCAL on a small