D3D12: IFence::Wait() returns immediately after the first time it has blocked (the manual-reset event is never reset) #816
Description
Where: Graphics/GraphicsEngineD3D12/src/FenceD3D12Impl.cpp, FenceD3D12Impl::Wait, as of DiligentCore master (read on 2026-10-21) and as of commit 074060c.
What the code does. The constructor creates one event with CreateEvent(NULL, TRUE /* manual-reset */, FALSE, NULL). Wait(Value) does:
if (GetCompletedValue() >= Value) return;
if (m_FenceCompleteEvent != NULL)
{
m_pd3d12Fence->SetEventOnCompletion(Value, m_FenceCompleteEvent);
WaitForSingleObject(m_FenceCompleteEvent, INFINITE);
}and nothing in Graphics/GraphicsEngineD3D12 ever calls ResetEvent on the handle. A manual-reset event stays signalled once it has been signalled, so after the first Wait that really blocked, every later Wait(Value) whose value the GPU has not reached yet calls SetEventOnCompletion and then returns from WaitForSingleObject at once, with GetCompletedValue() < Value.
What we saw. An application bounded its frames in flight with pFence->Wait(frame - 3) at the start of every frame (the fence is signalled with EnqueueSignal(pFence, frame) after each frame). With GPU-heavy frames, right after Wait(frame - 3) returned, GetCompletedValue() read frame - 4 (a gap of 4 where 3 is intended), over and over, from the second Wait that needed to block onward. Frames in flight were then unbounded, which broke the application's per-frame resource accounting. A test that records a GPU-heavy frame 40 times and checks GetCompletedValue() >= frame - 3 after every Wait fails from the fifth frame on D3D12 with this code. The Vulkan backend did not show it (by reading, its Wait is a timeline-semaphore wait where supported). I have not written a standalone reproduction outside our application; I can, if it would help.
Expected. Wait(Value) returns only when the fence has reached Value (or the device is lost).
Suggested fix. Reset the event before arming it (ResetEvent(m_FenceCompleteEvent) before SetEventOnCompletion); if Wait may be called from several threads at once, a per-call event is safer than one shared event. A loop on GetCompletedValue() after the wait is also cheap insurance.
Workaround on our side. Call Wait, then poll GetCompletedValue() (with a yield) until it reaches the value.
Activity
hzqst commentedon Oct 24, 2026
I hit this issue too, together with a separate bug, and fixed both in one PR:
IFence::Wait()that actually blocks, later waits can return before the fence reaches the requested value, because the shared manual-reset event is never reset.PR #817 fixes both, with one commit per issue, and adds a regression test for this one (
FenceTest.RepeatedCPUWait).