← Back to the desktop

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.

← Back to the desktop