-
Notifications
You must be signed in to change notification settings - Fork 5.4k
Don't use callbacks when performing sync IO with async handles #126845
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4d6f586
b3325a4
a34ad37
e387e23
99c34d4
d0fc732
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,8 +16,6 @@ namespace System.IO | |
| { | ||
| public static partial class RandomAccess | ||
| { | ||
| private static readonly IOCompletionCallback s_callback = AllocateCallback(); | ||
|
|
||
| internal static unsafe void SetFileLength(SafeFileHandle handle, long length) | ||
| { | ||
| var eofInfo = new Interop.Kernel32.FILE_END_OF_FILE_INFO | ||
|
|
@@ -69,52 +67,32 @@ _ when IsEndOfFile(errorCode, handle, fileOffset) => 0, | |
|
|
||
| private static unsafe int ReadSyncUsingAsyncHandle(SafeFileHandle handle, Span<byte> buffer, long fileOffset) | ||
| { | ||
| handle.EnsureThreadPoolBindingInitialized(); | ||
|
|
||
| CallbackResetEvent resetEvent = new CallbackResetEvent(handle.ThreadPoolBinding!); | ||
| IntPtr eventHandle = CreateSyncOverlappedEvent(); | ||
| NativeOverlapped* overlapped = null; | ||
|
|
||
| try | ||
| { | ||
| overlapped = GetNativeOverlappedForAsyncHandle(handle, fileOffset, resetEvent); | ||
| overlapped = AllocNativeOverlappedWithEventHandle(handle, fileOffset, eventHandle); | ||
|
|
||
| fixed (byte* pinned = &MemoryMarshal.GetReference(buffer)) | ||
| { | ||
| Interop.Kernel32.ReadFile(handle, pinned, buffer.Length, IntPtr.Zero, overlapped); | ||
|
|
||
| int errorCode = FileStreamHelpers.GetLastWin32ErrorAndDisposeHandleIfInvalid(handle); | ||
| if (errorCode == Interop.Errors.ERROR_IO_PENDING) | ||
| { | ||
| resetEvent.WaitOne(); | ||
| errorCode = Interop.Errors.ERROR_SUCCESS; | ||
| } | ||
|
|
||
| if (errorCode == Interop.Errors.ERROR_SUCCESS) | ||
| if (errorCode is Interop.Errors.ERROR_IO_PENDING or Interop.Errors.ERROR_SUCCESS) | ||
| { | ||
| int result = 0; | ||
| if (Interop.Kernel32.GetOverlappedResult(handle, overlapped, ref result, bWait: false)) | ||
| if (Interop.Kernel32.GetOverlappedResult(handle, overlapped, ref result, bWait: true)) | ||
| { | ||
| Debug.Assert(result >= 0 && result <= buffer.Length, $"GetOverlappedResult returned {result} for {buffer.Length} bytes request"); | ||
| return result; | ||
| } | ||
|
|
||
| errorCode = FileStreamHelpers.GetLastWin32ErrorAndDisposeHandleIfInvalid(handle); | ||
| } | ||
| else | ||
| { | ||
| // The initial errorCode was neither ERROR_IO_PENDING nor ERROR_SUCCESS, so the operation | ||
| // failed with an error and the callback won't be invoked. We thus need to decrement the | ||
| // ref count on the resetEvent that was initialized to a value under the expectation that | ||
| // the callback would be invoked and decrement it. | ||
| resetEvent.ReleaseRefCount(overlapped); | ||
| } | ||
|
|
||
| if (IsEndOfFile(errorCode, handle, fileOffset)) | ||
| { | ||
| // EOF on a pipe. Callback will not be called. | ||
| // We clear the overlapped status bit for this special case (failure | ||
| // to do so looks like we are freeing a pending overlapped later). | ||
| overlapped->InternalLow = IntPtr.Zero; | ||
| return 0; | ||
| } | ||
|
|
||
|
|
@@ -125,10 +103,10 @@ private static unsafe int ReadSyncUsingAsyncHandle(SafeFileHandle handle, Span<b | |
| { | ||
| if (overlapped != null) | ||
| { | ||
| resetEvent.ReleaseRefCount(overlapped); | ||
| NativeMemory.Free(overlapped); | ||
| } | ||
|
|
||
| resetEvent.Dispose(); | ||
| Interop.Kernel32.CloseHandle(eventHandle); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -166,45 +144,29 @@ private static unsafe void WriteSyncUsingAsyncHandle(SafeFileHandle handle, Read | |
| return; | ||
| } | ||
|
|
||
| handle.EnsureThreadPoolBindingInitialized(); | ||
|
|
||
| CallbackResetEvent resetEvent = new CallbackResetEvent(handle.ThreadPoolBinding!); | ||
| IntPtr eventHandle = CreateSyncOverlappedEvent(); | ||
| NativeOverlapped* overlapped = null; | ||
|
|
||
| try | ||
| { | ||
| overlapped = GetNativeOverlappedForAsyncHandle(handle, fileOffset, resetEvent); | ||
| overlapped = AllocNativeOverlappedWithEventHandle(handle, fileOffset, eventHandle); | ||
|
|
||
| fixed (byte* pinned = &MemoryMarshal.GetReference(buffer)) | ||
| { | ||
| Interop.Kernel32.WriteFile(handle, pinned, buffer.Length, IntPtr.Zero, overlapped); | ||
|
|
||
| int errorCode = FileStreamHelpers.GetLastWin32ErrorAndDisposeHandleIfInvalid(handle); | ||
| if (errorCode == Interop.Errors.ERROR_IO_PENDING) | ||
| { | ||
| resetEvent.WaitOne(); | ||
| errorCode = Interop.Errors.ERROR_SUCCESS; | ||
| } | ||
|
|
||
| if (errorCode == Interop.Errors.ERROR_SUCCESS) | ||
| if (errorCode is Interop.Errors.ERROR_IO_PENDING or Interop.Errors.ERROR_SUCCESS) | ||
| { | ||
| int result = 0; | ||
| if (Interop.Kernel32.GetOverlappedResult(handle, overlapped, ref result, bWait: false)) | ||
| if (Interop.Kernel32.GetOverlappedResult(handle, overlapped, ref result, bWait: true)) | ||
| { | ||
| Debug.Assert(result == buffer.Length, $"GetOverlappedResult returned {result} for {buffer.Length} bytes request"); | ||
| return; | ||
| } | ||
|
|
||
| errorCode = FileStreamHelpers.GetLastWin32ErrorAndDisposeHandleIfInvalid(handle); | ||
| } | ||
| else | ||
| { | ||
| // The initial errorCode was neither ERROR_IO_PENDING nor ERROR_SUCCESS, so the operation | ||
| // failed with an error and the callback won't be invoked. We thus need to decrement the | ||
| // ref count on the resetEvent that was initialized to a value under the expectation that | ||
| // the callback would be invoked and decrement it. | ||
| resetEvent.ReleaseRefCount(overlapped); | ||
| } | ||
|
|
||
| throw errorCode switch | ||
| { | ||
|
|
@@ -221,10 +183,10 @@ private static unsafe void WriteSyncUsingAsyncHandle(SafeFileHandle handle, Read | |
| { | ||
| if (overlapped != null) | ||
| { | ||
| resetEvent.ReleaseRefCount(overlapped); | ||
| NativeMemory.Free(overlapped); | ||
| } | ||
|
Comment on lines
183
to
187
|
||
|
|
||
| resetEvent.Dispose(); | ||
| Interop.Kernel32.CloseHandle(eventHandle); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -724,11 +686,25 @@ private static async ValueTask WriteGatherAtOffsetMultipleSyscallsAsync(SafeFile | |
| } | ||
| } | ||
|
|
||
| private static unsafe NativeOverlapped* GetNativeOverlappedForAsyncHandle(SafeFileHandle handle, long fileOffset, CallbackResetEvent resetEvent) | ||
| private static IntPtr CreateSyncOverlappedEvent() | ||
| { | ||
| IntPtr eventHandle = Interop.Kernel32.CreateEventExNoName( | ||
| IntPtr.Zero, | ||
| IntPtr.Zero, | ||
| Interop.Kernel32.CREATE_EVENT_MANUAL_RESET, | ||
| (uint)(Interop.Kernel32.SYNCHRONIZE | Interop.Kernel32.EVENT_MODIFY_STATE)); | ||
|
|
||
| if (eventHandle == IntPtr.Zero) | ||
| { | ||
| throw Win32Marshal.GetExceptionForLastWin32Error(); | ||
| } | ||
|
|
||
| return eventHandle; | ||
| } | ||
|
|
||
| private static unsafe NativeOverlapped* AllocNativeOverlappedWithEventHandle(SafeFileHandle handle, long fileOffset, IntPtr eventHandle) | ||
| { | ||
| // After SafeFileHandle is bound to ThreadPool, we need to use ThreadPoolBinding | ||
| // to allocate a native overlapped and provide a valid callback. | ||
| NativeOverlapped* result = handle.ThreadPoolBinding!.UnsafeAllocateNativeOverlapped(s_callback, resetEvent, null); | ||
| NativeOverlapped* result = (NativeOverlapped*)NativeMemory.AllocZeroed((nuint)sizeof(NativeOverlapped)); | ||
|
|
||
| if (handle.CanSeek) | ||
| { | ||
|
|
@@ -743,7 +719,11 @@ private static async ValueTask WriteGatherAtOffsetMultipleSyscallsAsync(SafeFile | |
| // are performed on the same file, named pipe, or communications device. | ||
| // In this situation, there is no way to know which operation caused the object's state to be signaled." | ||
| // Since we want RandomAccess APIs to be thread-safe, we provide a dedicated wait handle. | ||
| result->EventHandle = resetEvent.SafeWaitHandle.DangerousGetHandle(); | ||
| // From https://learn.microsoft.com/windows/win32/api/ioapiset/nf-ioapiset-getqueuedcompletionstatus: | ||
| // "If the file handle associated with the completion packet was previously associated with an I/O completion port | ||
| // [...] setting the low-order bit of hEvent in the OVERLAPPED structure prevents the I/O completion | ||
| // from being queued to a completion port." | ||
| result->EventHandle = eventHandle | 1; | ||
|
|
||
| return result; | ||
| } | ||
|
|
@@ -761,17 +741,6 @@ private static NativeOverlapped GetNativeOverlappedForSyncHandle(SafeFileHandle | |
| return result; | ||
| } | ||
|
|
||
| private static unsafe IOCompletionCallback AllocateCallback() | ||
| { | ||
| return new IOCompletionCallback(Callback); | ||
|
|
||
| static void Callback(uint errorCode, uint numBytes, NativeOverlapped* pOverlapped) | ||
| { | ||
| CallbackResetEvent state = (CallbackResetEvent)ThreadPoolBoundHandle.GetNativeOverlappedState(pOverlapped)!; | ||
| state.ReleaseRefCount(pOverlapped); | ||
| } | ||
| } | ||
|
|
||
| internal static bool IsEndOfFile(int errorCode, SafeFileHandle handle, long fileOffset) | ||
| { | ||
| switch (errorCode) | ||
|
|
@@ -798,32 +767,6 @@ internal static bool IsEndOfFile(int errorCode, SafeFileHandle handle, long file | |
| private static bool IsEndOfFileForNoBuffering(SafeFileHandle fileHandle, long fileOffset) | ||
| => fileHandle.IsNoBuffering && fileHandle.CanSeek && fileOffset >= fileHandle.GetFileLength(); | ||
|
|
||
| // We need to store the reference count (see the comment in ReleaseRefCount) and an EventHandle to signal the completion. | ||
| // We could keep these two things separate, but since ManualResetEvent is sealed and we want to avoid any extra allocations, this type has been created. | ||
| // It's basically ManualResetEvent with reference count. | ||
| private sealed class CallbackResetEvent : EventWaitHandle | ||
| { | ||
| private readonly ThreadPoolBoundHandle _threadPoolBoundHandle; | ||
| private int _freeWhenZero = 2; // one for the callback and another for the method that calls GetOverlappedResult | ||
|
|
||
| internal CallbackResetEvent(ThreadPoolBoundHandle threadPoolBoundHandle) : base(initialState: false, EventResetMode.ManualReset) | ||
| { | ||
| _threadPoolBoundHandle = threadPoolBoundHandle; | ||
| } | ||
|
|
||
| internal unsafe void ReleaseRefCount(NativeOverlapped* pOverlapped) | ||
| { | ||
| // Each SafeFileHandle opened for async IO is bound to ThreadPool. | ||
| // It requires us to provide a callback even if we want to use EventHandle and use GetOverlappedResult to obtain the result. | ||
| // There can be a race condition between the call to GetOverlappedResult and the callback invocation, | ||
| // so we need to track the number of references, and when it drops to zero, then free the native overlapped. | ||
| if (Interlocked.Decrement(ref _freeWhenZero) == 0) | ||
| { | ||
| _threadPoolBoundHandle.FreeNativeOverlapped(pOverlapped); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Abstracts away the type signature incompatibility between Memory and ReadOnlyMemory. | ||
| private interface IMemoryHandler<T> | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NativeMemory.Free(overlapped)is unconditional infinally. If theWaitOne()above throws (e.g.,ThreadInterruptedException) or the method otherwise exits before the OS has finished the overlapped operation, freeing this memory can result in use-after-free when the kernel writes completion state / signals the event. Consider restructuring so the overlapped is only freed after completion is guaranteed (e.g., useGetOverlappedResult(..., bWait: true)instead of managedWaitOne, or ensure completion in afinallybefore freeing).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ThreadInterruptedExceptionis legacy, I will defer to other code reviewers.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WaitOnecan throw arbitrary exceptions. Note that it can call back into user code via SynchronizationContext.We should not be corrupting unmanaged heap when that happens.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot let's try to implement following approach:
ManualResetEvent, instead perform the windows sys-call with minimal access right required to performReadFileAllocNativeOverlappedWithEventHandleinstead ofManualResetEventbWait = truetoGetOverlappedResultCloseHandle