The swapchain hazard my GPU forgave
A write-after-read synchronization bug in my Vulkan present path — found by a validation layer, not by symptoms. From RYU6Recomp, my clean-room console-rehosting research project. July 2026.
Context
RYU6Recomp is a personal research project: rehosting a commercial console demo binary as a native Windows application. The renderer replays the original game's frame through Vulkan; at the end of each frame, the present path blits the composited result onto a swapchain image and presents it. That present path is where this bug lived — for months, without a single visible symptom.
A bug with no symptoms
Output on my development machine's Intel iGPU was pixel-correct. The reason I found the bug at all is that I was chasing a different one: the same scene reproducibly lost the device on a discrete NVIDIA GPU. To narrow that down, I attached the Khronos validation layer's synchronization validation to the shipped loader — injected entirely from the environment, no code changes:
VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation
VK_LAYER_SETTINGS_PATH=vk_sync_validation_settings.txt
# vk_sync_validation_settings.txt
khronos_validation.enables = VK_VALIDATION_FEATURE_ENABLE_SYNCHRONIZATION_VALIDATION_EXT
khronos_validation.report_flags = error,warn
The result was unusually clean, as validation sweeps go: exactly one hazard class,
reported ten times, identical each time —
SYNC-HAZARD-WRITE-AFTER-READ on the swapchain image. Every internal
compute-to-graphics barrier in the frame came back clean. The frame was fine; the
present path was not.
Diagnosis
The present path does the standard dance: vkAcquireNextImageKHR hands back
an image index and signals an acquire semaphore; the present submit waits on that
semaphore with pWaitDstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT; the
command buffer's first act is a layout transition of the acquired image,
UNDEFINED → TRANSFER_DST, so the blit can write into it.
The transition barrier was sourced like this:
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, // srcStageMask
VK_PIPELINE_STAGE_TRANSFER_BIT, // dstStageMask
0, 0, nullptr, 0, nullptr, 1, &to_transfer_dst);
Here is the composition rule that makes this wrong. A semaphore wait only orders the
stages named in pWaitDstStageMask — everything else in the batch is free to
run before the semaphore signals. A pipeline barrier chains with that wait only if its
srcStageMask is equal to, or logically later in pipeline order than, the
waited stages. And
TOP_OF_PIPE as a source scope names no work at all: the barrier's first
synchronization scope is empty, the dependency is trivially satisfied, and the barrier
promises nothing about what came before it.
A layout transition is not metadata — it is a write to the image, performed by the
barrier itself. So the chain I thought I had (semaphore wait → transition → blit) did
not exist. The transition write was free to execute before the presentation engine had
finished reading the image that vkAcquireNextImageKHR handed back.
Write-after-read, exactly as reported. The iGPU's scheduling happened to make the race
unobservable, which is why it never showed on screen — but a hazard the driver forgives
is still a hazard.
The fix
Make the barrier's source scope the same stage the semaphore wait targets, so the execution dependency actually chains:
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, // srcStageMask
VK_PIPELINE_STAGE_TRANSFER_BIT, // srcStageMask — matches the
// acquire wait's pWaitDstStageMask
VK_PIPELINE_STAGE_TRANSFER_BIT, // dstStageMask
0, 0, nullptr, 0, nullptr, 1, &to_transfer_dst);
Three call sites in the present path had the same shape and got the same one-line fix: the composite blit, a fallback clear path submitted through the identical acquire-gated submit, and a screenshot-readback path that transitions the just-acquired image. Access masks stay zero: a write-after-read needs execution ordering, not memory visibility, and the validator's own remediation message says an execution dependency is sufficient. No over-synchronization, no broader stage masks than the hazard demands.
What the fix did — and didn't do
It closes the reported hazard by construction: the transition now cannot begin until the
stage the acquire semaphore gates has been released. I also added a source-level
regression test that pins the srcStageMask at all three call sites — a
narrow guard against this exact regression, not a substitute for re-running
synchronization validation, which stays in the project's toolkit for any new
acquire-gated path.
What it did not do is fix the bug I was originally hunting. Re-running the scene on the NVIDIA card after the fix still lost the device — which is a result in its own right: the device-loss is not a present-path barrier problem, and the two faults are distinct. That investigation moved on to GPU-assisted validation. It would have been easy to land the barrier fix and declare the crash fixed too; the separate re-test is what kept the two claims honest.
Lessons
Correct pixels prove nothing about synchronization. On a forgiving GPU, a real race can hide for the entire life of a project — synchronization validation is an instrument to run deliberately, not a last resort when something visibly breaks.
TOP_OF_PIPE as a srcStageMask is a declaration that a barrier
waits for nothing. If the first use of an acquired swapchain image happens at stage
X, wait the acquire semaphore at X and source the transition barrier
at X — that overlap is what makes the dependency chain compose.
And: the bug you find on the way is not the bug you were hunting. Verify each claim with its own test, or you'll ship one fix and two conclusions, one of them wrong.