diff --git a/src/ComputeSharp.Win32.D3D12/Windows/um/WinBase/Windows.cs b/src/ComputeSharp.Win32.D3D12/Windows/um/WinBase/Windows.cs index f98d2ba36..a1602d85f 100644 --- a/src/ComputeSharp.Win32.D3D12/Windows/um/WinBase/Windows.cs +++ b/src/ComputeSharp.Win32.D3D12/Windows/um/WinBase/Windows.cs @@ -9,12 +9,20 @@ namespace ComputeSharp.Win32; internal static unsafe partial class Windows { - [DllImport("kernel32", ExactSpelling = true)] - public static extern BOOL RegisterWaitForSingleObject([NativeTypeName("PHANDLE")] HANDLE* phNewWaitObject, HANDLE hObject, [NativeTypeName("WAITORTIMERCALLBACK")] delegate* unmanaged Callback, [NativeTypeName("PVOID")] void* Context, [NativeTypeName("ULONG")] uint dwMilliseconds, [NativeTypeName("ULONG")] uint dwFlags); + // Runtime marshalling is disabled for this assembly. LibraryImport generates + // the last-error capture needed by Marshal.GetLastPInvokeError; adding + // SetLastError to DllImport would rely on disabled runtime support. + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial BOOL RegisterWaitForSingleObject([NativeTypeName("PHANDLE")] HANDLE* phNewWaitObject, HANDLE hObject, [NativeTypeName("WAITORTIMERCALLBACK")] delegate* unmanaged Callback, [NativeTypeName("PVOID")] void* Context, [NativeTypeName("ULONG")] uint dwMilliseconds, [NativeTypeName("ULONG")] uint dwFlags); + + // The completion argument selects blocking or nonblocking cancellation. + // Capture last error to distinguish ERROR_IO_PENDING from cancellation failure. + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial BOOL UnregisterWaitEx(HANDLE WaitHandle, HANDLE CompletionEvent); [DllImport("kernel32", ExactSpelling = true)] public static extern BOOL UnregisterWait(HANDLE WaitHandle); [NativeTypeName("#define INFINITE 0xFFFFFFFF")] public const uint INFINITE = 0xFFFFFFFF; -} \ No newline at end of file +} diff --git a/src/ComputeSharp.Win32.D3D12/Windows/um/synchapi/Windows.cs b/src/ComputeSharp.Win32.D3D12/Windows/um/synchapi/Windows.cs index 694402604..12f7d9d1c 100644 --- a/src/ComputeSharp.Win32.D3D12/Windows/um/synchapi/Windows.cs +++ b/src/ComputeSharp.Win32.D3D12/Windows/um/synchapi/Windows.cs @@ -9,10 +9,12 @@ namespace ComputeSharp.Win32; internal static unsafe partial class Windows { - [DllImport("kernel32", ExactSpelling = true)] - public static extern HANDLE CreateEventW([NativeTypeName("LPSECURITY_ATTRIBUTES")] SECURITY_ATTRIBUTES* lpEventAttributes, BOOL bManualReset, BOOL bInitialState, [NativeTypeName("LPCWSTR")] ushort* lpName); + // Source-generated last-error capture works with DisableRuntimeMarshalling + // and lets fence-wait setup report the native error if event creation fails. + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial HANDLE CreateEventW([NativeTypeName("LPSECURITY_ATTRIBUTES")] SECURITY_ATTRIBUTES* lpEventAttributes, BOOL bManualReset, BOOL bInitialState, [NativeTypeName("LPCWSTR")] ushort* lpName); [DllImport("kernel32", ExactSpelling = true)] [return: NativeTypeName("DWORD")] public static extern uint WaitForSingleObjectEx(HANDLE hHandle, [NativeTypeName("DWORD")] uint dwMilliseconds, BOOL bAlertable); -} \ No newline at end of file +} diff --git a/src/ComputeSharp/Graphics/Commands/FenceWaitPublication.cs b/src/ComputeSharp/Graphics/Commands/FenceWaitPublication.cs new file mode 100644 index 000000000..2ca74e4ec --- /dev/null +++ b/src/ComputeSharp/Graphics/Commands/FenceWaitPublication.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; + +namespace ComputeSharp.Graphics.Commands; + +// The callback may run before RegisterWaitForSingleObject returns its handle. +// Exactly one of the publisher/callback owns cleanup, after both have arrived. +internal struct FenceWaitPublication +{ + // 0: neither participant arrived; 1: handle published; 2: callback arrived; + // 3: cleanup claimed. The first arrival must not access the context again + // after returning false, because the other participant may then free it. + private int state; + + public bool OnRegistered() + { + return Arrive(1, 2); + } + + public bool OnSignaled() + { + return Arrive(2, 1); + } + + private bool Arrive(int own, int other) + { + // Interlocked publishes the preceding context writes as well as the state. + // Only the second arrival can claim cleanup, once the handle is available + // and the callback has observed fence completion. + int previous = Interlocked.CompareExchange(ref this.state, own, 0); + if (previous == 0) + { + return false; + } + + if (previous == other && Interlocked.CompareExchange(ref this.state, 3, other) == other) + { + return true; + } + + throw new InvalidOperationException("Fence wait completion/publication occurred more than once."); + } + + public static bool UnregisterAccepted(bool success, int error, bool insideCallback) + { + // With a one-shot registration, this callback is the only outstanding + // callback. Nonblocking cancellation may report it as still running; + // the blocking publisher path must instead confirm completion. + return success || (insideCallback && error == 997); // ERROR_IO_PENDING: this callback is still executing. + } +} diff --git a/src/ComputeSharp/Graphics/GraphicsDevice.Execute.cs b/src/ComputeSharp/Graphics/GraphicsDevice.Execute.cs index a5beec311..1390ec8c7 100644 --- a/src/ComputeSharp/Graphics/GraphicsDevice.Execute.cs +++ b/src/ComputeSharp/Graphics/GraphicsDevice.Execute.cs @@ -134,6 +134,17 @@ private struct CallbackContext /// The event handle set when the target fence value is reached. /// public HANDLE EventHandle; + + /// + /// The registration to cancel before releasing the event and callback context. + /// A one-shot wait still requires explicit unregistration. + /// + public HANDLE WaitHandle; + + /// + /// Coordinates cleanup when the callback races publication of the registration handle. + /// + public FenceWaitPublication Publication; } /// @@ -151,39 +162,101 @@ private static WaitForFenceValueTaskSource WaitForFenceAsync( ID3D12GraphicsCommandList* d3D12GraphicsCommandList, ID3D12CommandAllocator* d3D12CommandAllocator) { - HANDLE eventHandle = Windows.CreateEventW(null, Windows.FALSE, Windows.FALSE, null); - - device.d3D12ComputeFence.Get()->SetEventOnCompletion(d3D12FenceValue, eventHandle).Assert(); - - WaitForFenceValueTaskSource waitForFenceValueTaskSource = WaitForFenceValueTaskSource.Rent(); - CallbackContext* callbackContext = (CallbackContext*)NativeMemory.Alloc((nuint)sizeof(CallbackContext)); - - callbackContext->WaitForFenceValueTaskSourceHandle = GCHandle.Alloc(waitForFenceValueTaskSource); - callbackContext->GraphicsDeviceHandle = GCHandle.Alloc(device); - callbackContext->D3D12GraphicsCommandList = d3D12GraphicsCommandList; - callbackContext->D3D12CommandAllocator = d3D12CommandAllocator; - callbackContext->EventHandle = eventHandle; - - HANDLE waitHandle; + WaitForFenceValueTaskSource? waitForFenceValueTaskSource = null; + CallbackContext* callbackContext = null; + bool referenceHeld = false; + try + { + waitForFenceValueTaskSource = WaitForFenceValueTaskSource.Rent(); + // Zero initialization supplies the handshake's initial state and lets + // failure cleanup distinguish acquired resources from uninitialized fields. + callbackContext = (CallbackContext*)NativeMemory.AllocZeroed((nuint)sizeof(CallbackContext)); + if (callbackContext is null) + { + throw new OutOfMemoryException(); + } - device.GetReferenceTracker().DangerousAddRef(); + callbackContext->WaitForFenceValueTaskSourceHandle = GCHandle.Alloc(waitForFenceValueTaskSource); + callbackContext->GraphicsDeviceHandle = GCHandle.Alloc(device); + callbackContext->D3D12GraphicsCommandList = d3D12GraphicsCommandList; + callbackContext->D3D12CommandAllocator = d3D12CommandAllocator; + device.GetReferenceTracker().DangerousAddRef(); + referenceHeld = true; + + callbackContext->EventHandle = Windows.CreateEventW(null, Windows.FALSE, Windows.FALSE, null); + // Do not pass a failed event allocation to SetEventOnCompletion: + // a null event has the distinct meaning of a synchronous fence wait. + if (callbackContext->EventHandle == default) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } - int result = Windows.RegisterWaitForSingleObject( - phNewWaitObject: &waitHandle, - hObject: eventHandle, - Callback: &WaitForSingleObjectCallbackForWaitForFenceAsync, - Context: callbackContext, - dwMilliseconds: Windows.INFINITE, - dwFlags: 0); + device.d3D12ComputeFence.Get()->SetEventOnCompletion(d3D12FenceValue, callbackContext->EventHandle).Assert(); + + // Queue at most one callback so that cleanup cannot race a second + // invocation. WT_EXECUTEONLYONCE does not release the registration. + HANDLE waitHandle; + int result = Windows.RegisterWaitForSingleObject( + phNewWaitObject: &waitHandle, + hObject: callbackContext->EventHandle, + Callback: &WaitForSingleObjectCallbackForWaitForFenceAsync, + Context: callbackContext, + dwMilliseconds: Windows.INFINITE, + dwFlags: 0x00000008); // WT_EXECUTEONLYONCE + if (result == 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } - // The register is successful if the return value is nonzero - if (result == 0) + // Publish the handle before allowing cleanup. A callback arriving + // early records completion and returns without touching it again. + callbackContext->WaitHandle = waitHandle; + } + catch { - device.GetReferenceTracker().DangerousRelease(); + // The command list was already submitted/detached. Drain it before + // closing an event registered with the fence or recycling its allocator. + try + { + device.d3D12ComputeFence.Get()->SetEventOnCompletion(d3D12FenceValue, default).Assert(); + device.computeCommandListPool.Return(d3D12GraphicsCommandList, d3D12CommandAllocator); + } + finally + { + if (callbackContext is not null) + { + if (callbackContext->WaitForFenceValueTaskSourceHandle.IsAllocated) + { + callbackContext->WaitForFenceValueTaskSourceHandle.Free(); + } + + if (callbackContext->GraphicsDeviceHandle.IsAllocated) + { + callbackContext->GraphicsDeviceHandle.Free(); + } + + if (callbackContext->EventHandle != default) + { + _ = Windows.CloseHandle(callbackContext->EventHandle); + } + + NativeMemory.Free(callbackContext); + } + + if (referenceHeld) + { + device.GetReferenceTracker().DangerousRelease(); + } + } - NativeMemory.Free(callbackContext); + throw; + } - default(Win32Exception).Throw(E.E_FAIL); + // Keep completion outside the setup-failure handler: after publication, + // the callback may own cleanup and may already have freed the context. + if (callbackContext->Publication.OnRegistered()) + { + CompleteFenceWait(callbackContext, insideCallback: false); } return waitForFenceValueTaskSource; @@ -199,7 +272,33 @@ private static void WaitForSingleObjectCallbackForWaitForFenceAsync(void* pConte { CallbackContext* callbackContext = (CallbackContext*)pContext; + if (callbackContext->Publication.OnSignaled()) + { + CompleteFenceWait(callbackContext, insideCallback: true); + } + } + + private static void CompleteFenceWait(CallbackContext* callbackContext, bool insideCallback) + { + // Never block on our own callback: NULL requests nonblocking cancellation. + // The early-callback/publisher path uses INVALID_HANDLE_VALUE to join the + // callback before freeing its context. Neither path closes the event first. + int unregistered = Windows.UnregisterWaitEx(callbackContext->WaitHandle, + insideCallback ? default : (HANDLE)(-1)); + int error = unregistered == 0 ? Marshal.GetLastPInvokeError() : 0; + Exception? failure = FenceWaitPublication.UnregisterAccepted(unregistered != 0, error, insideCallback) + ? null : new Win32Exception(error); + WaitForFenceValueTaskSource waitForFenceValueTaskSource = Unsafe.As(callbackContext->WaitForFenceValueTaskSourceHandle.Target)!; + if (failure is not null) + { + // Cancellation did not establish that the OS released the wait. + // Quarantine its resources and fault the caller rather than free + // a context/event whose native ownership is now uncertain. + waitForFenceValueTaskSource.Fail(failure); + return; + } + GraphicsDevice device = Unsafe.As(callbackContext->GraphicsDeviceHandle.Target)!; ID3D12GraphicsCommandList* d3D12GraphicsCommandList = callbackContext->D3D12GraphicsCommandList; ID3D12CommandAllocator* d3D12CommandAllocator = callbackContext->D3D12CommandAllocator; @@ -277,6 +376,11 @@ public void Complete() this.manualResetValueTaskSource.SetResult(null); } + public void Fail(Exception error) + { + this.manualResetValueTaskSource.SetException(error); + } + /// public void GetResult(short token) { @@ -306,4 +410,4 @@ public void OnCompleted(Action continuation, object? state, short token this.manualResetValueTaskSource.OnCompleted(continuation, state, token, flags); } } -} \ No newline at end of file +} diff --git a/tests/ComputeSharp.Tests.Internals/FenceWaitTests.cs b/tests/ComputeSharp.Tests.Internals/FenceWaitTests.cs new file mode 100644 index 000000000..8a48bcf66 --- /dev/null +++ b/tests/ComputeSharp.Tests.Internals/FenceWaitTests.cs @@ -0,0 +1,205 @@ +using System.ComponentModel; +using System.Diagnostics; +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Runtime.InteropServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ComputeSharp.Graphics.Commands; + +namespace ComputeSharp.Tests.Internals; + +[TestClass] +[DoNotParallelize] +public unsafe partial class FenceWaitTests +{ + private sealed class PublicationBox { public FenceWaitPublication Value; } + + private struct Context + { + public FenceWaitPublication Publication; + public nint Event; + public nint Wait; + public GCHandle Completion; + } + + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial nint CreateEventW(nint attributes, int manualReset, int initialState, nint name); + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial int SetEvent(nint handle); + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial int CloseHandle(nint handle); + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial int RegisterWaitForSingleObject(nint* wait, nint handle, + delegate* unmanaged callback, void* context, uint timeout, uint flags); + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial int UnregisterWaitEx(nint wait, nint completionEvent); + + [TestMethod] + public void PublicationAndNativeWaitLifetime() + { + foreach (bool callbackFirst in new[] { false, true }) + { + FenceWaitPublication value = default; + bool first = callbackFirst ? value.OnSignaled() : value.OnRegistered(); + bool second = callbackFirst ? value.OnRegistered() : value.OnSignaled(); + if (first || !second) + { + throw new Exception("Fence wait publication assigned the wrong cleanup owner."); + } + + try { _ = value.OnRegistered(); throw new Exception("Duplicate publication accepted."); } + catch (InvalidOperationException) { } + + try { _ = value.OnSignaled(); throw new Exception("Duplicate signal accepted."); } + catch (InvalidOperationException) { } + } + + _ = Parallel.For(0, 10_000, _ => + { + PublicationBox box = new(); + bool published = false, signaled = false; + Parallel.Invoke(() => published = box.Value.OnRegistered(), () => signaled = box.Value.OnSignaled()); + if (published == signaled) + { + throw new Exception("Concurrent publication did not produce exactly one cleanup owner."); + } + }); + if (!FenceWaitPublication.UnregisterAccepted(true, 0, false) + || !FenceWaitPublication.UnregisterAccepted(false, 997, true) + || FenceWaitPublication.UnregisterAccepted(false, 997, false) + || FenceWaitPublication.UnregisterAccepted(false, 6, true)) + { + throw new Exception("Unregister success/pending/error classification failed."); + } + + // Warm the thread pool before measuring kernel handles, then alternate + // callback-before-publication and callback-after-publication explicitly. + for (int i = 0; i < 32; i++) + { + NativeProbe(i % 2 == 0); + } + + int before = Handles(); + for (int i = 0; i < 2_000; i++) + { + NativeProbe(i % 2 == 0); + } + + Thread.Sleep(100); // OS finishes deferred deletion after callbacks return. + int after = Handles(); + if (after > before + 32) + { + throw new Exception($"Native wait registrations accumulated: handles {before} -> {after}."); + } + + // Verify source-generated last-error capture without handing an invalid + // synchronization object to the OS thread pool. + int invalid = SetEvent(0); + int error = Marshal.GetLastPInvokeError(); + if (invalid != 0 || error != 6) + { + throw new Exception($"Native failure did not preserve ERROR_INVALID_HANDLE: {invalid}/{error}."); + } + } + + private static int Handles() { using Process process = Process.GetCurrentProcess(); return process.HandleCount; } + + private static void NativeProbe(bool early) + { + TaskCompletionSource result = new(TaskCreationOptions.RunContinuationsAsynchronously); + Context* context = (Context*)NativeMemory.AllocZeroed((nuint)sizeof(Context)); + if (context is null) + { + throw new OutOfMemoryException(); + } + + context->Completion = GCHandle.Alloc(result); + context->Event = CreateEventW(0, 0, early ? 1 : 0, 0); + if (context->Event == 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + + nint wait; + if (RegisterWaitForSingleObject(&wait, context->Event, &Callback, context, uint.MaxValue, 8) == 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + + if (early) + { + // The test controls publication so the early path is guaranteed, + // rather than merely hoping the event callback wins a timing race. + Stopwatch deadline = Stopwatch.StartNew(); + while (!result.Task.IsCompleted && Volatile.Read(ref earlySignal) == 0) + { + if (deadline.Elapsed.TotalSeconds > 10) + { + throw new TimeoutException("Native early callback never arrived."); + } + + _ = Thread.Yield(); + } + + Volatile.Write(ref earlySignal, 0); + } + + context->Wait = wait; + if (context->Publication.OnRegistered()) + { + Cleanup(context, false); + } + else if (!early && SetEvent(context->Event) == 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + + if (!result.Task.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("Native wait cleanup never completed."); + } + + _ = result.Task.GetAwaiter().GetResult(); + } + + private static int earlySignal; + + [UnmanagedCallersOnly] + private static void Callback(void* pointer, byte timedOut) + { + Context* context = (Context*)pointer; + if (context->Publication.OnSignaled()) + { + Cleanup(context, true); + } + else + { + Volatile.Write(ref earlySignal, 1); + } + } + + private static void Cleanup(Context* context, bool insideCallback) + { + int ok = UnregisterWaitEx(context->Wait, insideCallback ? 0 : -1); + int error = ok == 0 ? Marshal.GetLastPInvokeError() : 0; + TaskCompletionSource completion = (TaskCompletionSource)context->Completion.Target!; + if (!FenceWaitPublication.UnregisterAccepted(ok != 0, error, insideCallback)) + { + completion.SetException(new Win32Exception(error)); + return; + } + + context->Completion.Free(); + int closed = CloseHandle(context->Event); + NativeMemory.Free(context); + if (closed == 0) + { + completion.SetException(new Exception("Native event close failed.")); + } + else + { + completion.SetResult(true); + } + } +} \ No newline at end of file