Synchronize and bound the Vulkan swapchain tracking (g_swaps)

g_swaps (vk_hook.cpp and the Vulkan layer) is pushed from the create-swapchain
detour and iterated by the present detour, which can run on different game
threads (Vulkan external sync is per-object, not global), and cleared on removal
from another thread -- all with no mutex. A push_back realloc could dangle the
SwapInfo* a concurrent find_swap/present is using. It was also never pruned, so a
game that recreates its swapchain each resize grew it without bound and could
match a recycled handle's stale images.

Add g_swaps_mutex around every access; the present detour now copies the matched
swapchain's fields out under the lock and captures without holding it (no GPU
submit under the lock, no dangling pointer). Create de-dups by handle and an LRU
cap (8) bounds growth -- the active swapchain is the newest, so it's never
evicted. Deliberately NOT hooking vkDestroySwapchainKHR: forwarding a destroy
incorrectly could break the game, and the de-dup + cap already bound growth and
defeat handle recycling.

Verified real by inspection (a concurrent-create+present Vulkan race isn't
deterministically reproducible in a test); validated by the full mock_game_test
Vulkan paths (capture + layer + too-late) staying green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 01:22:09 +02:00
parent bdb700ec56
commit 47462287fc
3 changed files with 98 additions and 15 deletions

View File

@@ -1,7 +1,9 @@
#include "vk_hook.hpp"
#include <algorithm>
#include <atomic>
#include <cstring>
#include <mutex>
#include <vector>
#include <windows.h>
@@ -72,6 +74,16 @@ struct SwapInfo
std::vector<VkImage> images;
};
std::vector<SwapInfo> g_swaps;
// g_swaps is pushed from the create-swapchain detour and read from the present detour, which can run
// on different game threads (Vulkan external-sync is per-object, not global), and cleared from the
// worker thread on removal. This mutex serializes all of that -- a push_back realloc must not race a
// concurrent find_swap iteration. We don't hook vkDestroySwapchainKHR (failing to forward a destroy
// could break the game); instead create de-dups by handle and an LRU cap bounds growth, so a game
// that recreates its swapchain on every resize can't grow g_swaps without bound or match a recycled
// handle's stale images. The active swapchain is always the most-recently created, so it's never
// evicted.
std::mutex g_swaps_mutex;
constexpr std::size_t kMaxTrackedSwaps = 8; // engines use 1-3; headroom for transient resize overlap
// --- the real vkGetInstanceProcAddr, via the inline hook's trampoline --------
PFN_vkVoidFunction real_gipa(VkInstance inst, const char* name)
@@ -79,6 +91,8 @@ PFN_vkVoidFunction real_gipa(VkInstance inst, const char* name)
return g_hk_gipa.stdcall<PFN_vkVoidFunction>(inst, name);
}
// Caller must hold g_swaps_mutex; the returned pointer is only valid until the lock is released
// (copy out what you need before unlocking, since another thread can push_back and reallocate).
const SwapInfo* find_swap(VkSwapchainKHR sc)
{
for (const SwapInfo& s : g_swaps)
@@ -107,12 +121,30 @@ VKAPI_ATTR VkResult VKAPI_CALL hk_vkQueuePresentKHR(VkQueue queue, const VkPrese
if (g_capture_enabled.load(std::memory_order_acquire) && g_device != VK_NULL_HANDLE &&
pPresentInfo != nullptr && pPresentInfo->swapchainCount == 1)
{
const SwapInfo* s = find_swap(pPresentInfo->pSwapchains[0]);
if (s != nullptr && pPresentInfo->pImageIndices[0] < s->images.size())
// Copy the matched swapchain's fields out under the lock, then capture without holding it (so
// the GPU submit can't block a concurrent create, and the SwapInfo* can't dangle on a realloc).
VkImage image = VK_NULL_HANDLE;
VkFormat fmt = VK_FORMAT_UNDEFINED;
std::uint32_t w = 0, h = 0;
bool matched = false;
{
std::scoped_lock lock(g_swaps_mutex);
const SwapInfo* s = find_swap(pPresentInfo->pSwapchains[0]);
const std::uint32_t idx = pPresentInfo->pImageIndices[0];
if (s != nullptr && idx < s->images.size())
{
image = s->images[idx];
fmt = s->fmt;
w = s->w;
h = s->h;
matched = true;
}
}
if (matched)
{
VkSemaphore chained = VK_NULL_HANDLE;
if (g_cap.present(s->images[pPresentInfo->pImageIndices[0]], s->fmt, s->w, s->h,
pPresentInfo->pWaitSemaphores, pPresentInfo->waitSemaphoreCount, chained))
if (g_cap.present(image, fmt, w, h, pPresentInfo->pWaitSemaphores,
pPresentInfo->waitSemaphoreCount, chained))
{
// Replace the present's wait with our chained semaphore (our submit consumed the
// originals and signals this one), so the present still orders after rendering.
@@ -142,7 +174,19 @@ VKAPI_ATTR VkResult VKAPI_CALL hk_vkCreateSwapchainKHR(VkDevice device, const Vk
g_get_swapchain_images(device, *out, &n, nullptr);
info.images.resize(n);
g_get_swapchain_images(device, *out, &n, info.images.data());
g_swaps.push_back(std::move(info));
{
std::scoped_lock lock(g_swaps_mutex);
// De-dup a recycled handle value, then bound growth (drop the oldest; the just-created
// active swapchain is newest and stays).
g_swaps.erase(std::remove_if(g_swaps.begin(), g_swaps.end(),
[&](const SwapInfo& e) { return e.sc == info.sc; }),
g_swaps.end());
g_swaps.push_back(std::move(info));
if (g_swaps.size() > kMaxTrackedSwaps)
{
g_swaps.erase(g_swaps.begin());
}
}
// (Don't log the VkSwapchainKHR handle: it's a uint64_t on x86, not a pointer.)
logf("vk: swapchain %ux%u fmt=%d images=%u", ci->imageExtent.width, ci->imageExtent.height,
static_cast<int>(ci->imageFormat), n);
@@ -345,7 +389,10 @@ void remove_vk_hooks()
g_cap.shutdown(); // joins the reaper, drains the device, frees the read-back resources
hook_set_installed(g_id_present, false);
g_swaps.clear();
{
std::scoped_lock lock(g_swaps_mutex); // drained above, but keep all g_swaps access serialized
g_swaps.clear();
}
g_get_swapchain_images = nullptr;
g_device = VK_NULL_HANDLE;
g_instance = VK_NULL_HANDLE;