Skip to content

Commit a87cf1b

Browse files
authored
Fix FileSystemWatcher state cleanup on dispose/finalization on Windows (#116830)
* Fix FileSystemWatcher state cleanup on dispose/finalization on Windows When the FSW is disposed or finalized while it's in use, on Windows the implementation disposes of the directory handle as a mechanism to stop the monitoring. If by that point the state object's weak reference to the FSW has been cleared out (which it always will have been in the case of finalization), the state object's relevant members are not disposed, leaking several objects. This fixes that to ensure the state is cleaned up, by cleaning it up when the callback is unable to get the FSW instance. This also addresses concerns over long-pinned managed buffers by switching to using a native buffer. Today the buffer is allocated and then immediately pinned, due to being passed immediately to the OS via a P/Invoke, and all writes to that buffer happen in unmanaged code. All reading also is already happening via a bounds-checked span. So instead of allocating and pinning a managed array, just allocate a native buffer. * Address PR feedback * Address PR feedback
1 parent f0168ee commit a87cf1b

File tree

4 files changed

+102
-63
lines changed

4 files changed

+102
-63
lines changed

src/libraries/Common/src/Interop/Windows/Kernel32/Interop.ReadDirectoryChangesW.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ internal static partial class Kernel32
1414
[return: MarshalAs(UnmanagedType.Bool)]
1515
internal static unsafe partial bool ReadDirectoryChangesW(
1616
SafeFileHandle hDirectory,
17-
byte[] lpBuffer,
17+
void* lpBuffer,
1818
uint nBufferLength,
1919
[MarshalAs(UnmanagedType.Bool)] bool bWatchSubtree,
2020
uint dwNotifyFilter,

src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ private void StartRaisingEvents()
8484
}
8585
}
8686

87+
/// <summary>Allocates a buffer of the requested internal buffer size.</summary>
88+
/// <returns>The allocated buffer.</returns>
89+
private byte[] AllocateBuffer()
90+
{
91+
try
92+
{
93+
return new byte[_internalBufferSize];
94+
}
95+
catch (OutOfMemoryException)
96+
{
97+
throw new OutOfMemoryException(SR.Format(SR.BufferSizeTooLarge, _internalBufferSize));
98+
}
99+
}
100+
87101
/// <summary>Cancels the currently running watch operation if there is one.</summary>
88102
private void StopRaisingEvents()
89103
{

src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Win32.cs

Lines changed: 87 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -25,58 +25,67 @@ private void StartRaisingEvents()
2525
}
2626

2727
// If we're already running, don't do anything.
28-
if (!IsHandleInvalid(_directoryHandle))
28+
if (!IsHandleClosed(_directoryHandle))
2929
return;
3030

3131
// Create handle to directory being monitored
32-
_directoryHandle = Interop.Kernel32.CreateFile(
32+
SafeFileHandle directoryHandle = Interop.Kernel32.CreateFile(
3333
lpFileName: _directory,
3434
dwDesiredAccess: Interop.Kernel32.FileOperations.FILE_LIST_DIRECTORY,
3535
dwShareMode: FileShare.Read | FileShare.Delete | FileShare.Write,
3636
dwCreationDisposition: FileMode.Open,
3737
dwFlagsAndAttributes: Interop.Kernel32.FileOperations.FILE_FLAG_BACKUP_SEMANTICS | Interop.Kernel32.FileOperations.FILE_FLAG_OVERLAPPED);
3838

39-
if (IsHandleInvalid(_directoryHandle))
39+
if (directoryHandle.IsInvalid)
4040
{
41-
_directoryHandle = null;
4241
throw new FileNotFoundException(SR.Format(SR.FSW_IOError, _directory));
4342
}
4443

4544
// Create the state associated with the operation of monitoring the direction
46-
AsyncReadState state;
45+
AsyncReadState state = new(
46+
Interlocked.Increment(ref _currentSession), // ignore all events that were initiated before this
47+
this,
48+
directoryHandle);
4749
try
4850
{
49-
// Start ignoring all events that were initiated before this, and
50-
// allocate the buffer to be pinned and used for the duration of the operation
51-
int session = Interlocked.Increment(ref _currentSession);
52-
byte[] buffer = AllocateBuffer();
51+
unsafe
52+
{
53+
uint bufferSize = _internalBufferSize;
54+
state.Buffer = NativeMemory.Alloc(bufferSize);
55+
state.BufferByteLength = bufferSize;
56+
}
57+
58+
state.ThreadPoolBinding = ThreadPoolBoundHandle.BindHandle(directoryHandle);
5359

5460
// Store all state, including a preallocated overlapped, into the state object that'll be
5561
// passed from iteration to iteration during the lifetime of the operation. The buffer will be pinned
5662
// from now until the end of the operation.
57-
state = new AsyncReadState(session, buffer, _directoryHandle, ThreadPoolBoundHandle.BindHandle(_directoryHandle), this);
5863
unsafe
5964
{
6065
state.PreAllocatedOverlapped = new PreAllocatedOverlapped((errorCode, numBytes, overlappedPointer) =>
6166
{
6267
AsyncReadState state = (AsyncReadState)ThreadPoolBoundHandle.GetNativeOverlappedState(overlappedPointer)!;
63-
state.ThreadPoolBinding.FreeNativeOverlapped(overlappedPointer);
68+
state.ThreadPoolBinding!.FreeNativeOverlapped(overlappedPointer);
69+
6470
if (state.WeakWatcher.TryGetTarget(out FileSystemWatcher? watcher))
6571
{
6672
watcher.ReadDirectoryChangesCallback(errorCode, numBytes, state);
6773
}
68-
}, state, buffer);
74+
else
75+
{
76+
state.Dispose();
77+
}
78+
}, state, null);
6979
}
7080
}
7181
catch
7282
{
73-
// Make sure we don't leave a valid directory handle set if we're not running
74-
_directoryHandle.Dispose();
75-
_directoryHandle = null;
83+
state.Dispose();
7684
throw;
7785
}
7886

7987
// Start monitoring
88+
_directoryHandle = directoryHandle;
8089
_enabled = true;
8190
Monitor(state);
8291
}
@@ -90,7 +99,7 @@ private void StopRaisingEvents()
9099
return;
91100

92101
// If we're not running, do nothing.
93-
if (IsHandleInvalid(_directoryHandle))
102+
if (IsHandleClosed(_directoryHandle))
94103
return;
95104

96105
// Start ignoring all events occurring after this.
@@ -118,7 +127,7 @@ private void FinalizeDispose()
118127
// We must explicitly dispose the handle to ensure it gets closed before this object is finalized.
119128
// Otherwise, it is possible that the GC will decide to finalize the handle after this,
120129
// leaving a window of time where our callback could be invoked on a non-existent object.
121-
if (!IsHandleInvalid(_directoryHandle))
130+
if (!IsHandleClosed(_directoryHandle))
122131
_directoryHandle.Dispose();
123132
}
124133

@@ -128,11 +137,23 @@ private void FinalizeDispose()
128137
// Unmanaged handle to monitored directory
129138
private SafeFileHandle? _directoryHandle;
130139

131-
private static bool IsHandleInvalid([NotNullWhen(false)] SafeFileHandle? handle)
140+
/// <summary>Allocates a buffer of the requested internal buffer size.</summary>
141+
/// <returns>The allocated buffer.</returns>
142+
private unsafe byte* AllocateBuffer()
132143
{
133-
return handle == null || handle.IsInvalid || handle.IsClosed;
144+
try
145+
{
146+
return (byte*)NativeMemory.Alloc(_internalBufferSize);
147+
}
148+
catch (OutOfMemoryException)
149+
{
150+
throw new OutOfMemoryException(SR.Format(SR.BufferSizeTooLarge, _internalBufferSize));
151+
}
134152
}
135153

154+
private static bool IsHandleClosed([NotNullWhen(false)] SafeFileHandle? handle) =>
155+
handle is null || handle.IsClosed;
156+
136157
/// <summary>
137158
/// Initiates the next asynchronous read operation if monitoring is still desired.
138159
/// If the directory handle has been closed due to an error or due to event monitoring
@@ -141,6 +162,7 @@ private static bool IsHandleInvalid([NotNullWhen(false)] SafeFileHandle? handle)
141162
private unsafe void Monitor(AsyncReadState state)
142163
{
143164
Debug.Assert(state.PreAllocatedOverlapped != null);
165+
Debug.Assert(state.ThreadPoolBinding is not null && state.Buffer is not null, "Members should have been set at construction");
144166

145167
// This method should only ever access the directory handle via the state object passed in, and not access it
146168
// via _directoryHandle. While this function is executing asynchronously, another thread could set
@@ -150,35 +172,35 @@ private unsafe void Monitor(AsyncReadState state)
150172

151173
NativeOverlapped* overlappedPointer = null;
152174
bool continueExecuting = false;
175+
int win32Error = 0;
153176
try
154177
{
155178
// If shutdown has been requested, exit. The finally block will handle
156179
// cleaning up the entire operation, as continueExecuting will remain false.
157-
if (!_enabled || IsHandleInvalid(state.DirectoryHandle))
180+
if (!_enabled || IsHandleClosed(state.DirectoryHandle))
158181
return;
159182

160183
// Get the overlapped pointer to use for this iteration.
161184
overlappedPointer = state.ThreadPoolBinding.AllocateNativeOverlapped(state.PreAllocatedOverlapped);
162185
continueExecuting = Interop.Kernel32.ReadDirectoryChangesW(
163186
state.DirectoryHandle,
164-
state.Buffer, // the buffer is kept pinned for the duration of the sync and async operation by the PreAllocatedOverlapped
165-
(uint)state.Buffer.Length,
187+
state.Buffer,
188+
(uint)state.BufferByteLength,
166189
_includeSubdirectories,
167190
(uint)_notifyFilters,
168191
null,
169192
overlappedPointer,
170193
null);
194+
if (!continueExecuting)
195+
{
196+
win32Error = Marshal.GetLastWin32Error();
197+
}
171198
}
172199
catch (ObjectDisposedException)
173200
{
174201
// Ignore. Disposing of the handle is the mechanism by which the FSW communicates
175202
// to the asynchronous operation to stop processing.
176203
}
177-
catch (ArgumentNullException)
178-
{
179-
//Ignore. The disposed handle could also manifest as an ArgumentNullException.
180-
Debug.Assert(IsHandleInvalid(state.DirectoryHandle), "ArgumentNullException from something other than SafeHandle?");
181-
}
182204
finally
183205
{
184206
// At this point the operation has either been initiated and we'll let the callback
@@ -192,15 +214,18 @@ private unsafe void Monitor(AsyncReadState state)
192214
state.ThreadPoolBinding.FreeNativeOverlapped(overlappedPointer);
193215
}
194216

195-
// Clean up the thread pool binding created for the entire operation
196-
state.PreAllocatedOverlapped.Dispose();
197-
state.ThreadPoolBinding.Dispose();
217+
// Check whether the directory handle is still valid _before_ we dispose of the state,
218+
// which will invalidate the handle.
219+
bool handleClosed = IsHandleClosed(state.DirectoryHandle);
220+
221+
// Clean up the state created for the whole operation.
222+
state.Dispose();
198223

199224
// Finally, if the handle was for some reason changed or closed during this call,
200225
// then don't throw an exception. Otherwise, it's a valid error.
201-
if (!IsHandleInvalid(state.DirectoryHandle))
226+
if (!handleClosed)
202227
{
203-
OnError(new ErrorEventArgs(new Win32Exception()));
228+
OnError(new ErrorEventArgs(new Win32Exception(win32Error)));
204229
}
205230
}
206231
}
@@ -211,7 +236,7 @@ private void ReadDirectoryChangesCallback(uint errorCode, uint numBytes, AsyncRe
211236
{
212237
try
213238
{
214-
if (IsHandleInvalid(state.DirectoryHandle))
239+
if (IsHandleClosed(state.DirectoryHandle))
215240
return;
216241

217242
if (errorCode != 0)
@@ -236,13 +261,17 @@ private void ReadDirectoryChangesCallback(uint errorCode, uint numBytes, AsyncRe
236261
if (state.Session != Volatile.Read(ref _currentSession))
237262
return;
238263

239-
if (numBytes == 0)
264+
if (numBytes == 0 || numBytes > state.BufferByteLength)
240265
{
266+
Debug.Assert(numBytes == 0, "ReadDirectoryChangesW returned more bytes than the buffer can hold!");
241267
NotifyInternalBufferOverflowEvent();
242268
}
243269
else
244270
{
245-
ParseEventBufferAndNotifyForEach(new ReadOnlySpan<byte>(state.Buffer, 0, (int)numBytes));
271+
unsafe
272+
{
273+
ParseEventBufferAndNotifyForEach(new ReadOnlySpan<byte>(state.Buffer, (int)numBytes));
274+
}
246275
}
247276
}
248277
finally
@@ -374,26 +403,36 @@ private unsafe void ParseEventBufferAndNotifyForEach(ReadOnlySpan<byte> buffer)
374403
/// </summary>
375404
private sealed class AsyncReadState
376405
{
377-
internal AsyncReadState(int session, byte[] buffer, SafeFileHandle handle, ThreadPoolBoundHandle binding, FileSystemWatcher parent)
406+
internal AsyncReadState(int session, FileSystemWatcher parent, SafeFileHandle directoryHandle)
378407
{
379-
Debug.Assert(buffer != null);
380-
Debug.Assert(buffer.Length > 0);
381-
Debug.Assert(handle != null);
382-
Debug.Assert(binding != null);
383-
384408
Session = session;
385-
Buffer = buffer;
386-
DirectoryHandle = handle;
387-
ThreadPoolBinding = binding;
388409
WeakWatcher = new WeakReference<FileSystemWatcher>(parent);
410+
DirectoryHandle = directoryHandle;
389411
}
390412

391413
internal int Session { get; }
392-
internal byte[] Buffer { get; }
393-
internal SafeFileHandle DirectoryHandle { get; }
394-
internal ThreadPoolBoundHandle ThreadPoolBinding { get; }
395-
internal PreAllocatedOverlapped? PreAllocatedOverlapped { get; set; }
396414
internal WeakReference<FileSystemWatcher> WeakWatcher { get; }
415+
internal SafeFileHandle DirectoryHandle { get; set; }
416+
417+
internal unsafe void* Buffer { get; set; }
418+
internal uint BufferByteLength { get; set; }
419+
420+
internal ThreadPoolBoundHandle? ThreadPoolBinding { get; set; }
421+
422+
internal PreAllocatedOverlapped? PreAllocatedOverlapped { get; set; }
423+
424+
public void Dispose()
425+
{
426+
unsafe
427+
{
428+
NativeMemory.Free(Buffer);
429+
Buffer = null;
430+
}
431+
432+
PreAllocatedOverlapped?.Dispose();
433+
ThreadPoolBinding?.Dispose();
434+
DirectoryHandle?.Dispose();
435+
}
397436
}
398437
}
399438
}

src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.cs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -223,20 +223,6 @@ public int InternalBufferSize
223223
}
224224
}
225225

226-
/// <summary>Allocates a buffer of the requested internal buffer size.</summary>
227-
/// <returns>The allocated buffer.</returns>
228-
private byte[] AllocateBuffer()
229-
{
230-
try
231-
{
232-
return new byte[_internalBufferSize];
233-
}
234-
catch (OutOfMemoryException)
235-
{
236-
throw new OutOfMemoryException(SR.Format(SR.BufferSizeTooLarge, _internalBufferSize));
237-
}
238-
}
239-
240226
/// <devdoc>
241227
/// Gets or sets the path of the directory to watch.
242228
/// </devdoc>

0 commit comments

Comments
 (0)