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

@@ -114,9 +114,6 @@ From an in-depth review pass. Each item is fixed test-first (a failing test, the
as its own commit; "verify" items are confirmed real before any change, and dropped if not. as its own commit; "verify" items are confirmed real before any change, and dropped if not.
Correctness (verify, then fix if real): Correctness (verify, then fix if real):
- **`vk_hook` / `vk_layer` `g_swaps`** — no synchronization on push/iterate and never pruned on
swapchain destroy (unbounded growth + stale-handle match). Add a guard + a `vkDestroySwapchainKHR`
prune.
- **`mkb_hook` raw-input slot reuse** — a 64-slot ring can overwrite an event before the game reads - **`mkb_hook` raw-input slot reuse** — a 64-slot ring can overwrite an event before the game reads
the `WM_INPUT`. Verify; fix if real. the `WM_INPUT`. Verify; fix if real.
- **Non-atomic cross-process diagnostic counters** — `present_calls`, `frames_dropped`, - **Non-atomic cross-process diagnostic counters** — `present_calls`, `frames_dropped`,

View File

@@ -1,7 +1,9 @@
#include "vk_hook.hpp" #include "vk_hook.hpp"
#include <algorithm>
#include <atomic> #include <atomic>
#include <cstring> #include <cstring>
#include <mutex>
#include <vector> #include <vector>
#include <windows.h> #include <windows.h>
@@ -72,6 +74,16 @@ struct SwapInfo
std::vector<VkImage> images; std::vector<VkImage> images;
}; };
std::vector<SwapInfo> g_swaps; 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 -------- // --- the real vkGetInstanceProcAddr, via the inline hook's trampoline --------
PFN_vkVoidFunction real_gipa(VkInstance inst, const char* name) 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); 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) const SwapInfo* find_swap(VkSwapchainKHR sc)
{ {
for (const SwapInfo& s : g_swaps) 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 && if (g_capture_enabled.load(std::memory_order_acquire) && g_device != VK_NULL_HANDLE &&
pPresentInfo != nullptr && pPresentInfo->swapchainCount == 1) pPresentInfo != nullptr && pPresentInfo->swapchainCount == 1)
{ {
const SwapInfo* s = find_swap(pPresentInfo->pSwapchains[0]); // Copy the matched swapchain's fields out under the lock, then capture without holding it (so
if (s != nullptr && pPresentInfo->pImageIndices[0] < s->images.size()) // 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; VkSemaphore chained = VK_NULL_HANDLE;
if (g_cap.present(s->images[pPresentInfo->pImageIndices[0]], s->fmt, s->w, s->h, if (g_cap.present(image, fmt, w, h, pPresentInfo->pWaitSemaphores,
pPresentInfo->pWaitSemaphores, pPresentInfo->waitSemaphoreCount, chained)) pPresentInfo->waitSemaphoreCount, chained))
{ {
// Replace the present's wait with our chained semaphore (our submit consumed the // 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. // 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); g_get_swapchain_images(device, *out, &n, nullptr);
info.images.resize(n); info.images.resize(n);
g_get_swapchain_images(device, *out, &n, info.images.data()); 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.) // (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, logf("vk: swapchain %ux%u fmt=%d images=%u", ci->imageExtent.width, ci->imageExtent.height,
static_cast<int>(ci->imageFormat), n); 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 g_cap.shutdown(); // joins the reaper, drains the device, frees the read-back resources
hook_set_installed(g_id_present, false); 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_get_swapchain_images = nullptr;
g_device = VK_NULL_HANDLE; g_device = VK_NULL_HANDLE;
g_instance = VK_NULL_HANDLE; g_instance = VK_NULL_HANDLE;

View File

@@ -15,10 +15,12 @@
// The loader/layer interface structs (VkLayer*CreateInfo, VkNegotiateLayerInterface) live in // The loader/layer interface structs (VkLayer*CreateInfo, VkNegotiateLayerInterface) live in
// vk_layer.h, which Vulkan-Headers doesn't ship, so they're declared here to the stable // vk_layer.h, which Vulkan-Headers doesn't ship, so they're declared here to the stable
// loader-interface-version-2 ABI. // loader-interface-version-2 ABI.
#include <algorithm>
#include <atomic> #include <atomic>
#include <cstdarg> #include <cstdarg>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <mutex>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -132,6 +134,12 @@ struct SwapInfo
std::vector<VkImage> images; std::vector<VkImage> images;
}; };
std::vector<SwapInfo> g_swaps; std::vector<SwapInfo> g_swaps;
// Serialize all g_swaps access: create (push) and present (find) can run on different game threads,
// and DestroyDevice clears it. We don't track swapchain destruction (forwarding a destroy wrong could
// break the game); create de-dups by handle and an LRU cap bounds growth so repeated resize/recreate
// can't grow g_swaps unbounded or match a recycled handle's stale images.
std::mutex g_swaps_mutex;
constexpr std::size_t kMaxTrackedSwaps = 8;
bool eq(const char* a, const char* b) bool eq(const char* a, const char* b)
{ {
@@ -209,6 +217,8 @@ bool decide_active()
return _stricmp(self8, want) == 0; return _stricmp(self8, want) == 0;
} }
// Caller must hold g_swaps_mutex; copy out what you need before unlocking (a concurrent push_back
// can reallocate and dangle the returned pointer).
const SwapInfo* find_swap(VkSwapchainKHR sc) const SwapInfo* find_swap(VkSwapchainKHR sc)
{ {
for (const SwapInfo& s : g_swaps) for (const SwapInfo& s : g_swaps)
@@ -268,12 +278,28 @@ VKAPI_ATTR VkResult VKAPI_CALL layer_QueuePresentKHR(VkQueue queue, const VkPres
} }
if (g_active && pi != nullptr && pi->swapchainCount == 1) if (g_active && pi != nullptr && pi->swapchainCount == 1)
{ {
const SwapInfo* s = find_swap(pi->pSwapchains[0]); // Copy the matched swapchain out under the lock, then capture without holding it.
if (s != nullptr && pi->pImageIndices[0] < s->images.size()) 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(pi->pSwapchains[0]);
const std::uint32_t idx = pi->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; VkSemaphore chained = VK_NULL_HANDLE;
if (g_cap.present(s->images[pi->pImageIndices[0]], s->fmt, s->w, s->h, pi->pWaitSemaphores, if (g_cap.present(image, fmt, w, h, pi->pWaitSemaphores, pi->waitSemaphoreCount, chained))
pi->waitSemaphoreCount, chained))
{ {
VkPresentInfoKHR p = *pi; VkPresentInfoKHR p = *pi;
p.waitSemaphoreCount = 1; p.waitSemaphoreCount = 1;
@@ -305,7 +331,17 @@ VKAPI_ATTR VkResult VKAPI_CALL layer_CreateSwapchainKHR(VkDevice device, const V
g_get_swapchain_images(device, *out, &n, nullptr); g_get_swapchain_images(device, *out, &n, nullptr);
info.images.resize(n); info.images.resize(n);
g_get_swapchain_images(device, *out, &n, info.images.data()); g_get_swapchain_images(device, *out, &n, info.images.data());
g_swaps.push_back(std::move(info)); {
std::scoped_lock lock(g_swaps_mutex);
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());
}
}
} }
return r; return r;
} }
@@ -435,7 +471,10 @@ VKAPI_ATTR void VKAPI_CALL layer_DestroyDevice(VkDevice device, const VkAllocati
if (g_active && device == g_device) if (g_active && device == g_device)
{ {
g_cap.shutdown(); // joins the reaper, drains the device, frees the read-back resources g_cap.shutdown(); // joins the reaper, drains the device, frees the read-back resources
g_swaps.clear(); {
std::scoped_lock lock(g_swaps_mutex);
g_swaps.clear();
}
g_device = VK_NULL_HANDLE; g_device = VK_NULL_HANDLE;
} }
destroy(device, a); destroy(device, a);