From d67ff4528ac3b1bab3db53909726634965838861 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Mon, 30 Jun 2025 16:03:11 +0200 Subject: [PATCH 01/26] FileSystemWatcher.Linux: use a single inotify instance and refactor watch tracking. --- .../Linux/System.Native/Interop.INotify.cs | 1 + .../src/System.IO.FileSystem.Watcher.csproj | 5 + .../src/System/IO/FileSystemWatcher.Linux.cs | 1692 ++++++++++------- .../src/System/IO/FileSystemWatcher.cs | 6 +- 4 files changed, 1040 insertions(+), 664 deletions(-) diff --git a/src/libraries/Common/src/Interop/Linux/System.Native/Interop.INotify.cs b/src/libraries/Common/src/Interop/Linux/System.Native/Interop.INotify.cs index 446bdb4ada25f6..4960583633e2b6 100644 --- a/src/libraries/Common/src/Interop/Linux/System.Native/Interop.INotify.cs +++ b/src/libraries/Common/src/Interop/Linux/System.Native/Interop.INotify.cs @@ -56,6 +56,7 @@ internal enum NotifyEvents IN_ONLYDIR = 0x01000000, IN_DONT_FOLLOW = 0x02000000, IN_EXCL_UNLINK = 0x04000000, + IN_MASK_ADD = 0x20000000, IN_ISDIR = 0x40000000, } } diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj b/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj index 8544de48d095d5..2c700efae9c48e 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj @@ -135,4 +135,9 @@ + + + + + diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 4687691395fbb4..ea1de24d6c07db 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -1,11 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.Win32.SafeHandles; @@ -16,6 +18,8 @@ namespace System.IO // each subdirectory, causing a race between adding the watch and file system events happening. public partial class FileSystemWatcher { + private const int PATH_MAX = 4096; + /// Starts a new watch operation if one is not currently running. private void StartRaisingEvents() { @@ -26,76 +30,14 @@ private void StartRaisingEvents() return; } - // If we already have a cancellation object, we're already running. - if (_cancellation != null) + // If we already have a watcher object, we're already running. + if (_watcher != null) { return; } - // Open an inotify file descriptor. - SafeFileHandle handle = Interop.Sys.INotifyInit(); - if (handle.IsInvalid) - { - Interop.ErrorInfo error = Interop.Sys.GetLastErrorInfo(); - handle.Dispose(); - switch (error.Error) - { - case Interop.Error.EMFILE: - string? maxValue = ReadMaxUserLimit(MaxUserInstancesPath); - string message = !string.IsNullOrEmpty(maxValue) ? - SR.Format(SR.IOException_INotifyInstanceUserLimitExceeded_Value, maxValue) : - SR.IOException_INotifyInstanceUserLimitExceeded; - throw new IOException(message, error.RawErrno); - case Interop.Error.ENFILE: - throw new IOException(SR.IOException_INotifyInstanceSystemLimitExceeded, error.RawErrno); - default: - throw Interop.GetExceptionForIoErrno(error); - } - } - - try - { - // Create the cancellation object that will be used by this FileSystemWatcher to cancel the new watch operation - CancellationTokenSource cancellation = new CancellationTokenSource(); - - // Start running. All state associated with the watch operation is stored in a separate object; this is done - // to avoid race conditions that could result if the users quickly starts/stops/starts/stops/etc. causing multiple - // active operations to all be outstanding at the same time. - var runner = new RunningInstance( - this, handle, _directory, - IncludeSubdirectories, NotifyFilter, cancellation.Token); - - // Now that we've created the runner, store the cancellation object and mark the instance - // as running. We wait to do this so that if there was a failure, StartRaisingEvents - // may be called to try again without first having to call StopRaisingEvents. - _cancellation = cancellation; - _enabled = true; - - // Start the runner - runner.Start(); - } - catch - { - // If we fail to actually start the watching even though we've opened the - // inotify handle, close the inotify handle proactively rather than waiting for it - // to be finalized. - handle.Dispose(); - throw; - } - } - - /// Allocates a buffer of the requested internal buffer size. - /// The allocated buffer. - private byte[] AllocateBuffer() - { - try - { - return new byte[_internalBufferSize]; - } - catch (OutOfMemoryException) - { - throw new OutOfMemoryException(SR.Format(SR.BufferSizeTooLarge, _internalBufferSize)); - } + _watcher = INotify.StartWatcher(this); + _enabled = true; } /// Cancels the currently running watch operation if there is one. @@ -106,21 +48,14 @@ private void StopRaisingEvents() if (IsSuspended()) return; - // If there's an active cancellation token, cancel and release it. - // The cancellation token and the processing task respond to cancellation - // to handle all other cleanup. - var cts = _cancellation; - if (cts != null) - { - _cancellation = null; - cts.Cancel(); - } + _watcher?.Stop(); + _watcher = null; } /// Called when FileSystemWatcher is finalized. private void FinalizeDispose() { - // The RunningInstance remains rooted and holds open the SafeFileHandle until it's explicitly + // The Watcher remains rooted and holds open the SafeFileHandle until it's explicitly // torn down. FileSystemWatcher.Dispose will call StopRaisingEvents, but not on finalization; // thus we need to explicitly call it here. StopRaisingEvents(); @@ -132,11 +67,7 @@ private void FinalizeDispose() /// Path to the procfs file that contains the maximum number of inotify watches an individual user may create. private const string MaxUserWatchesPath = "/proc/sys/fs/inotify/max_user_watches"; - /// - /// Cancellation for the currently running watch operation. - /// This is non-null if an operation has been started and null if stopped. - /// - private CancellationTokenSource? _cancellation; + private INotify.Watcher? _watcher; /// Reads the value of a max user limit path from procfs. /// The path to read. @@ -147,656 +78,773 @@ private void FinalizeDispose() catch { return null; } } - /// - /// Maps the FileSystemWatcher's NotifyFilters enumeration to the - /// corresponding Interop.Sys.NotifyEvents values. - /// - /// The filters provided the by user. - /// The corresponding NotifyEvents values to use with inotify. - private static Interop.Sys.NotifyEvents TranslateFilters(NotifyFilters filters) + // Implementation notes: + // + // Path vs directory: + // Note that inotify does not watch a path, but it watches directories. + // When a path is passed to inotify_add_watch, the directory is looked up by the kernel and a watch descriptor (wd) is returned for watching that directory. + // If the directory is moved to a different path, inotify will continue to reports its events. + // If we have previously added a watch for a path, and we call inotify_add_watch again for that path then: + // - if the looked up directory is still the same, the same wd will be returned, or + // - if the path now refers to a different directory, another wd will be returned. + // + // For each FileSystemWatcher we use a Watcher object that represents all inotify operations performed for that FileSystemWatcher. + // To represent the difference explained above (path vs directory) we use a WatchDirectory object to represent a path that is watched + // and a separate Watch object that represent the wd returned by the inotify_add_watch. + // Each WatchDirectory has a single Watch, while a Watch may be used by several WatchDirectories. + // When there are no more WatchDirectories using the Watch, we can remove it. + // + // Locking: + // To prevent deadlocks, the locks (as needed) should be taken in this order: _addLock/s_watchersLock, lock on Watcher instance, lock on Watch instance. + // + // Shared inotify instance: + // By default, the number of inotify instances per user is limited to 128. + // Because of this low limit, we make all the FileSystemWatchers share a single inotify instance to reduce contention with other processes. + // A dedicated thread dequeues the inotify events. From the inotify events, FileSystemWatcher events are emitted from the ThreadPool. + // This stops FileSystemWatcher event handlers to block one another, or them blocking the inotify thread which could cause the inotify event queue to overflow. + // This requires us to use IN_MASK_ADD which may cause us to continue receive events that no FileSystemWatcher is still interested in. + private sealed class INotify { - Interop.Sys.NotifyEvents result = 0; - - // We always include a few special inotify watch values that configure - // the watch's behavior. - result |= - Interop.Sys.NotifyEvents.IN_ONLYDIR | // we only allow watches on directories - Interop.Sys.NotifyEvents.IN_EXCL_UNLINK; // we want to stop monitoring unlinked files - - // For the Created and Deleted events, we need to always - // register for the created/deleted inotify events, regardless - // of the supplied filters values. We explicitly don't include IN_DELETE_SELF. - // The Windows implementation doesn't include notifications for the root directory, - // and having this for subdirectories results in duplicate notifications, one from - // the parent and one from self. - result |= - Interop.Sys.NotifyEvents.IN_CREATE | - Interop.Sys.NotifyEvents.IN_DELETE; - - // For the Changed event, which inotify events we subscribe to - // are based on the NotifyFilters supplied. - const NotifyFilters filtersForAccess = - NotifyFilters.LastAccess; - const NotifyFilters filtersForModify = - NotifyFilters.LastAccess | - NotifyFilters.LastWrite | - NotifyFilters.Security | - NotifyFilters.Size; - const NotifyFilters filtersForAttrib = - NotifyFilters.Attributes | - NotifyFilters.CreationTime | - NotifyFilters.LastAccess | - NotifyFilters.LastWrite | - NotifyFilters.Security | - NotifyFilters.Size; - if ((filters & filtersForAccess) != 0) - { - result |= Interop.Sys.NotifyEvents.IN_ACCESS; - } - if ((filters & filtersForModify) != 0) - { - result |= Interop.Sys.NotifyEvents.IN_MODIFY; - } - if ((filters & filtersForAttrib) != 0) - { - result |= Interop.Sys.NotifyEvents.IN_ATTRIB; - } + // Guards the watchers of the inotify instance. + public static readonly object s_watchersLock = new(); + + private static INotify? _currentInotify; - // For the Rename event, we'll register for the corresponding move inotify events if the - // caller's NotifyFilters asks for notifications related to names. - const NotifyFilters filtersForMoved = - NotifyFilters.FileName | - NotifyFilters.DirectoryName; - if ((filters & filtersForMoved) != 0) + public static Watcher? StartWatcher(FileSystemWatcher fsw) { - result |= - Interop.Sys.NotifyEvents.IN_MOVED_FROM | - Interop.Sys.NotifyEvents.IN_MOVED_TO; - } + Watcher watcher; + lock (s_watchersLock) + { + // If there is no running instance, start one. + if (_currentInotify is null || _currentInotify.IsStopped) + { + INotify inotify = new(s_watchersLock); + inotify.Start(); + _currentInotify = inotify; + } - return result; - } + watcher = _currentInotify.CreateWatcherCore(fsw); + } + + watcher.Start(); + return watcher; + } - /// - /// State and processing associated with an active watch operation. This state is kept separate from FileSystemWatcher to avoid - /// race conditions when a user starts/stops/starts/stops/etc. in quick succession, resulting in the potential for multiple - /// active operations. It also helps with avoiding rooted cycles and enabling proper finalization. - /// - private sealed class RunningInstance - { /// /// The size of the native struct inotify_event. 4 32-bit integer values, the last of which is a length /// that indicates how many bytes follow to form the string name. /// private const int c_INotifyEventSize = 16; - /// - /// Weak reference to the associated watcher. A weak reference is used so that the FileSystemWatcher may be collected and finalized, - /// causing an active operation to be torn down. With a strong reference, a blocking read on the inotify handle will keep alive this - /// instance which will keep alive the FileSystemWatcher which will not be finalizable and thus which will never signal to the blocking - /// read to wake up in the event that the user neglects to stop raising events. - /// - private readonly WeakReference _weakWatcher; - /// - /// The path for the primary watched directory. - /// - private readonly string _directoryPath; - /// - /// The inotify handle / file descriptor - /// + public bool IsStopped { get; private set; } + + private readonly object _watchersLock; + private readonly List _watchers = new(); + private readonly byte[] _buffer = new byte[16384]; private readonly SafeFileHandle _inotifyHandle; - /// - /// Buffer used to store raw bytes read from the inotify handle. - /// - private readonly byte[] _buffer; - /// - /// The number of bytes read into the _buffer. - /// + private readonly ConcurrentDictionary _wdToWatch = new ConcurrentDictionary(); + private readonly ReaderWriterLockSlim _addLock = new(LockRecursionPolicy.NoRecursion); + private int _bufferAvailable; - /// - /// The next position in _buffer from which an event should be read. - /// private int _bufferPos; - /// - /// Filters to use when adding a watch on directories. - /// - private readonly NotifyFilters _notifyFilters; - private readonly Interop.Sys.NotifyEvents _watchFilters; - /// - /// Whether to monitor subdirectories. Unlike Win32, inotify does not implicitly monitor subdirectories; - /// watches must be explicitly added for those subdirectories. - /// - private readonly bool _includeSubdirectories; - /// - /// Token to monitor for cancellation requests, upon which processing is stopped and all - /// state is cleaned up. - /// - private readonly CancellationToken _cancellationToken; - /// - /// Mapping from watch descriptor (as returned by inotify_add_watch) to state for - /// the associated directory being watched. Events from inotify include only relative - /// names, so the watch descriptor in an event must be used to look up the associated - /// directory path in order to convert the relative filename into a full path. - /// - private readonly Dictionary _wdToPathMap = new Dictionary(); - /// - /// Maximum length of a name returned from inotify event. - /// - private const int NAME_MAX = 255; // from limits.h + private WatchedDirectory[] _dirBuffer = new WatchedDirectory[4]; - /// Initializes the instance with all state necessary to operate a watch. - internal RunningInstance( - FileSystemWatcher watcher, SafeFileHandle inotifyHandle, string directoryPath, - bool includeSubdirectories, NotifyFilters notifyFilters, CancellationToken cancellationToken) + public INotify(object watcherLock) { - Debug.Assert(watcher != null); - Debug.Assert(inotifyHandle != null && !inotifyHandle.IsInvalid && !inotifyHandle.IsClosed); - Debug.Assert(directoryPath != null); - - _weakWatcher = new WeakReference(watcher); - _inotifyHandle = inotifyHandle; - _directoryPath = directoryPath; - _buffer = watcher.AllocateBuffer(); - Debug.Assert(_buffer != null && _buffer.Length > (c_INotifyEventSize + NAME_MAX + 1)); - _includeSubdirectories = includeSubdirectories; - _notifyFilters = notifyFilters; - _watchFilters = TranslateFilters(notifyFilters); - _cancellationToken = cancellationToken; - - // Add a watch for this starting directory. We keep track of the watch descriptor => directory information - // mapping in a dictionary; this is needed in order to be able to determine the containing directory - // for all notifications so that we can reconstruct the full path. - AddDirectoryWatchUnlocked(null, directoryPath); + _watchersLock = watcherLock; + + _inotifyHandle = CreateINotifyHandle(); + + static SafeFileHandle CreateINotifyHandle() + { + SafeFileHandle handle = Interop.Sys.INotifyInit(); + + if (handle.IsInvalid) + { + Interop.ErrorInfo error = Interop.Sys.GetLastErrorInfo(); + handle.Dispose(); + switch (error.Error) + { + case Interop.Error.EMFILE: + string? maxValue = ReadMaxUserLimit(MaxUserInstancesPath); + string message = !string.IsNullOrEmpty(maxValue) ? + SR.Format(SR.IOException_INotifyInstanceUserLimitExceeded_Value, maxValue) : + SR.IOException_INotifyInstanceUserLimitExceeded; + throw new IOException(message, error.RawErrno); + case Interop.Error.ENFILE: + throw new IOException(SR.IOException_INotifyInstanceSystemLimitExceeded, error.RawErrno); + default: + throw Interop.GetExceptionForIoErrno(error); + } + } + + return handle; + } } - internal void Start() + public Watcher CreateWatcherCore(FileSystemWatcher fsw) { - // Spawn a thread to read from the inotify queue and process the events. - new Thread(obj => ((RunningInstance)obj!).ProcessEvents()) + Debug.Assert(Monitor.IsEntered(_watchersLock)); + + var watcher = new Watcher(this, fsw); + + // We only add to the watchers if this is effectively watching something. + if (watcher.CreateRootWatch()) { - IsBackground = true, - Name = ".NET File Watcher" - }.Start(this); + _watchers.Add(watcher); + } - // PERF: As needed, we can look into making this use async I/O rather than burning - // a thread that blocks in the read syscall. + return watcher; } - /// Object to use for synchronizing access to state when necessary. - private object SyncObj { get { return _wdToPathMap; } } - - /// Adds a watch on a directory to the existing inotify handle. - /// The parent directory entry. - /// The new directory path to monitor, relative to the root. - private void AddDirectoryWatch(WatchedDirectory parent, string directoryName) + public void Start() { - lock (SyncObj) + Debug.Assert(Monitor.IsEntered(_watchersLock)); + + try { - // The read syscall on the file descriptor will block until either close is called or until - // all previously added watches are removed. We don't want to rely on close, as a) that could - // lead to race conditions where we inadvertently read from a recycled file descriptor, and b) - // the SafeFileHandle that wraps the file descriptor can't be disposed (thus closing - // the underlying file descriptor and allowing read to wake up) while there's an active ref count - // against the handle, so we'd deadlock if we relied on that approach. Instead, we want to follow - // the approach of removing all watches when we're done, which means we also don't want to - // add any new watches once the count hits zero. - if (_wdToPathMap.Count > 0) + // Spawn a thread to read from the inotify queue and process the events. + Thread thread = new Thread(obj => ((INotify)obj!).ProcessEvents()) { - AddDirectoryWatchUnlocked(parent, directoryName); - } + IsBackground = true, + Name = ".NET File Watcher" + }; + thread.Start(this); + } + catch + { + Stop(); + + throw; } } - /// Adds a watch on a directory to the existing inotify handle. - /// The parent directory entry. - /// The new directory path to monitor, relative to the root. - private void AddDirectoryWatchUnlocked(WatchedDirectory? parent, string directoryName) + private void Stop() { - bool hasParent = parent != null; - string fullPath = hasParent ? parent!.GetPath(false, directoryName) : directoryName; + // note: this method gets called only on the ProcessEvents thread, or when that thread fails to start. + Debug.Assert(Monitor.IsEntered(_watchersLock)); + Debug.Assert(!IsStopped); - // inotify_add_watch will fail if this is a symlink, so check that we didn't get a symlink - // with the exception of the watched directory where we try to dereference the path. - if (hasParent && - (Interop.Sys.LStat(fullPath, out Interop.Sys.FileStatus status) == 0) && - ((status.Mode & (uint)Interop.Sys.FileTypes.S_IFMT) == Interop.Sys.FileTypes.S_IFLNK)) + IsStopped = true; + + // Before we can close the inotify handle we need to sync so no further watches may be added/removed: + // Sync with AddOrUpdateWatchedDirectory. + foreach (var watcher in _watchers) { - return; + lock (watcher) + { } } + // Sync with RemoveUnusedINotifyWatches. + _addLock.EnterWriteLock(); + _addLock.ExitWriteLock(); - // Add a watch for the full path. If the path is already being watched, this will return - // the existing descriptor. This works even in the case of a rename. We also add the DONT_FOLLOW (for subdirectories only) - // and EXCL_UNLINK flags to keep parity with Windows where we don't pickup symlinks or unlinked - // files (which don't exist in Windows) - uint mask = (uint)(_watchFilters | Interop.Sys.NotifyEvents.IN_EXCL_UNLINK | (hasParent ? Interop.Sys.NotifyEvents.IN_DONT_FOLLOW : 0)); - int wd = Interop.Sys.INotifyAddWatch(_inotifyHandle, fullPath, mask); - if (wd == -1) - { - // If we get an error when trying to add the watch, don't let that tear down processing. Instead, - // raise the Error event with the exception and let the user decide how to handle it. + _inotifyHandle.Dispose(); + } - Interop.ErrorInfo error = Interop.Sys.GetLastErrorInfo(); + public WatchedDirectory? AddOrUpdateWatchedDirectory(Watcher watcher, WatchedDirectory? parent, string directoryPath, Interop.Sys.NotifyEvents watchFilters, bool followLinks = false, bool ignoreMissing = true) + { + WatchedDirectory? inotifyWatchesToRemove = null; + WatchedDirectory dir; - // Don't report an error when we can't add a watch because the child directory - // was removed or replaced by a file. - if (hasParent && (error.Error == Interop.Error.ENOENT || - error.Error == Interop.Error.ENOTDIR)) + _addLock.EnterReadLock(); + try + { + // Serialize adding watches to the same watcher. + // Concurrently adding watches may happen during the initial reursive iteration of the directory. + // This ensures the WatchedDirectory matches with the most recent INotifyAddWatch directory. + // The lock on the watcher is also used to synchronizes with Stop. + lock (watcher) { - return; - } + if (IsStopped || watcher.IsStopped) + { + return null; + } - Exception exc; - if (error.Error == Interop.Error.ENOSPC) - { - string? maxValue = ReadMaxUserLimit(MaxUserWatchesPath); - string message = !string.IsNullOrEmpty(maxValue) ? - SR.Format(SR.IOException_INotifyWatchesUserLimitExceeded_Value, maxValue) : - SR.IOException_INotifyWatchesUserLimitExceeded; - exc = new IOException(message, error.RawErrno); - } - else - { - exc = Interop.GetExceptionForIoErrno(error, fullPath); - } + Interop.Sys.NotifyEvents mask = watchFilters | + Interop.Sys.NotifyEvents.IN_ONLYDIR | // we only allow watches on directories + Interop.Sys.NotifyEvents.IN_EXCL_UNLINK | // we want to stop monitoring unlinked files + (followLinks ? 0 : Interop.Sys.NotifyEvents.IN_DONT_FOLLOW | + Interop.Sys.NotifyEvents.IN_MASK_ADD); - if (_weakWatcher.TryGetTarget(out FileSystemWatcher? watcher)) - { - watcher.OnError(new ErrorEventArgs(exc)); + // To support multiple FileSystemWatchers on the same inotify instance, we need to use IN_MASK_ADD + // so we don't remove events another watcher is interested in. + // The downside is that we won't unsubscribe from events that are unique to a watcher when it stops. + mask |= Interop.Sys.NotifyEvents.IN_MASK_ADD; + + if (watcher.IncludeSubdirectories) + { + mask |= Interop.Sys.NotifyEvents.IN_CREATE | Interop.Sys.NotifyEvents.IN_MOVED_TO | Interop.Sys.NotifyEvents.IN_MOVED_FROM; + } + + int wd = Interop.Sys.INotifyAddWatch(_inotifyHandle, directoryPath, (uint)mask); + if (wd == -1) + { + // If we get an error when trying to add the watch, don't let that tear down processing. + // Instead, raise the Error event with the exception and let the user decide how to handle it. + Interop.ErrorInfo error = Interop.Sys.GetLastErrorInfo(); + + // Don't report an error when we can't add a watch because the child directory was removed or replaced by a file. + if (ignoreMissing && (error.Error == Interop.Error.ENOENT || error.Error == Interop.Error.ENOTDIR)) + { + return null; + } + + Exception exc; + if (error.Error == Interop.Error.ENOSPC) + { + string? maxValue = ReadMaxUserLimit(MaxUserWatchesPath); + string message = !string.IsNullOrEmpty(maxValue) ? + SR.Format(SR.IOException_INotifyWatchesUserLimitExceeded_Value, maxValue) : + SR.IOException_INotifyWatchesUserLimitExceeded; + exc = new IOException(message, error.RawErrno); + } + else + { + exc = Interop.GetExceptionForIoErrno(error, directoryPath); + } + + watcher.QueueError(exc); + + return null; + } + + Watch watch = _wdToWatch.AddOrUpdate(wd, (int wd) => new Watch(wd), (int wd, Watch current) => current); + + if (parent is null) + { + Debug.Assert(watcher.RootDirectory is null); + dir = new WatchedDirectory(watch, watcher, "", parent); + } + else + { + // Check if the parent already has a watch for this child name. + string name = System.IO.Path.GetFileName(directoryPath); + int idx = parent.FindChild(name); + if (idx != -1) + { + dir = parent.Children![idx]; + if (dir.Watch == watch) + { + // The inotify watch is the same. + return dir; + } + + // The current watch is watching a different directory, use the new watch instead. + bool removeINotifyWatches = false; + + RemoveWatchedDirectoryFromParentAndWatches(dir, ref removeINotifyWatches); + + if (removeINotifyWatches) + { + inotifyWatchesToRemove = dir; + } + } + dir = new WatchedDirectory(watch, watcher, name, parent); + parent.InitializedChildren.Add(dir); + } + + lock (watch) + { + watch.Watchers.Add(dir); + } } + } + finally + { + _addLock.ExitReadLock(); + } + + if (inotifyWatchesToRemove is not null) + { + RemoveUnusedINotifyWatches(inotifyWatchesToRemove); + } + + return dir; + } + + public void RemoveWatchedDirectory(WatchedDirectory dir, int ignoredFd = -1) + { + bool removeINotifyWatches = false; + + RemoveWatchedDirectoryFromParentAndWatches(dir, ref removeINotifyWatches, ignoredFd); - return; + if (removeINotifyWatches) + { + RemoveUnusedINotifyWatches(dir, ignoredFd); } + } - // Then store the path information into our map. - WatchedDirectory? directoryEntry; - bool isNewDirectory = false; - if (_wdToPathMap.TryGetValue(wd, out directoryEntry)) + private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignoredFd = -1) + { + // _addLock stops handles from being added while we'll removing watches. + // This is needed to prevent removing watch descriptors between INotifyAddWatch and adding them to the Watch.Watchers. + // _addLock is also used to synchronizes with Stop. + _addLock.EnterWriteLock(); + try { - // The watch descriptor was already in the map. Hard links on directories - // aren't possible, and symlinks aren't annotated as IN_ISDIR, - // so this is a rename. (In extremely remote cases, this could be - // a recycled watch descriptor if many, many events were lost - // such that our dictionary got very inconsistent with the state - // of the world, but there's little that can be done about that.) - if (directoryEntry.Parent != parent) + if (IsStopped) { - // Work around https://github.com/dotnet/csharplang/issues/3393 preventing Parent?.Children!. from behaving as expected - if (directoryEntry.Parent != null) - { - directoryEntry.Parent.Children!.Remove(directoryEntry); - } + return; + } - directoryEntry.Parent = parent; - if (hasParent) + RemoveINotifyWatchWhenNoMoreWatchers(removedDir.Watch, ignoredFd); + + if (removedDir.Children is { } children) + { + foreach (var child in children) { - parent!.InitializedChildren.Add(directoryEntry); + RemoveINotifyWatchWhenNoMoreWatchers(child.Watch, ignoredFd); } } - directoryEntry.Name = directoryName; } - else + finally + { + _addLock.ExitWriteLock(); + } + + void RemoveINotifyWatchWhenNoMoreWatchers(Watch watch, int ignoredFd) { - // The watch descriptor wasn't in the map. This is a creation. - directoryEntry = new WatchedDirectory - { - Parent = parent, - WatchDescriptor = wd, - Name = directoryName - }; - if (hasParent) + lock (watch) { - parent!.InitializedChildren.Add(directoryEntry); + if (watch.Watchers.Count == 0) + { + if (_wdToWatch.TryRemove(watch.WatchDescriptor, out _)) + { + if (watch.WatchDescriptor != ignoredFd) + { + Interop.Sys.INotifyRemoveWatch(_inotifyHandle, watch.WatchDescriptor); + } + } + } } - _wdToPathMap.Add(wd, directoryEntry); - isNewDirectory = true; } + } - // Since inotify doesn't handle nesting implicitly, explicitly - // add a watch for each child directory if the developer has - // asked for subdirectories to be included. - if (isNewDirectory && _includeSubdirectories) + private static void RemoveWatchedDirectoryFromParentAndWatches(WatchedDirectory dir, ref bool removeINotifyWatches, int ignoredFd = -1) + { + Watcher watcher = dir.Watcher; + lock (watcher) { - try + if (dir.Parent is null) { - // This method is recursive. If we expect to see hierarchies - // so deep that it would cause us to overflow the stack, we could - // consider using an explicit stack object rather than recursion. - // This is unlikely, however, given typical directory names - // and max path limits. - foreach (string subDir in Directory.EnumerateDirectories(fullPath)) + if (watcher.RootDirectory == null) { - AddDirectoryWatchUnlocked(directoryEntry, System.IO.Path.GetFileName(subDir)); - // AddDirectoryWatchUnlocked will add the new directory to - // this.Children, so we don't have to / shouldn't also do it here. + return; // Already removed. } + watcher.RootDirectory = null; } - catch (DirectoryNotFoundException) - { } // The child directory was removed. - catch (IOException ex) when (ex.HResult == Interop.Error.ENOTDIR.Info().RawErrno) - { } // The child directory was replaced by a file. - catch (Exception ex) + else + { + int idx = dir.Parent.FindChild(dir.Name); + Debug.Assert(idx != -1); + if (idx == -1) + { + return; // Already removed. + } + dir.Parent.Children!.RemoveAt(idx); + } + + RemoveFromWatch(dir, ignoredFd, ref removeINotifyWatches); + + if (dir.Children is { } children) { - if (_weakWatcher.TryGetTarget(out FileSystemWatcher? watcher)) + foreach (var child in children) { - watcher.OnError(new ErrorEventArgs(ex)); + RemoveFromWatch(child, ignoredFd, ref removeINotifyWatches); } } } - } - /// Removes the watched directory from our state, and optionally removes the inotify watch itself. - /// The directory entry to remove. - /// true to remove the inotify watch; otherwise, false. The default is true. - private void RemoveWatchedDirectory(WatchedDirectory directoryEntry, bool removeInotify = true) - { - Debug.Assert(_includeSubdirectories); - lock (SyncObj) + static void RemoveFromWatch(WatchedDirectory dir, int ignoredFd, ref bool removeINotifyWatches) { - // Work around https://github.com/dotnet/csharplang/issues/3393 preventing Parent?.Children!. from behaving as expected - if (directoryEntry.Parent != null) + Watch watch = dir.Watch; + lock (watch) { - directoryEntry.Parent.Children!.Remove(directoryEntry); + watch.Watchers.Remove(dir); + if (watch.WatchDescriptor != ignoredFd) + { + removeINotifyWatches |= watch.Watchers.Count == 0; + } } - - RemoveWatchedDirectoryUnlocked(directoryEntry, removeInotify); } } - /// Removes the watched directory from our state, and optionally removes the inotify watch itself. - /// The directory entry to remove. - /// true to remove the inotify watch; otherwise, false. The default is true. - private void RemoveWatchedDirectoryUnlocked(WatchedDirectory directoryEntry, bool removeInotify) + private void ProcessEvents() { - // If the directory has children, recursively remove them (see comments on recursion in AddDirectoryWatch). - if (directoryEntry.Children != null) + lock (_watchersLock) { - foreach (WatchedDirectory child in directoryEntry.Children) + // We've started an INotify, but no root watch was created for the FileSystemWatcher that started it. + if (_watchers.Count == 0) { - RemoveWatchedDirectoryUnlocked(child, removeInotify); + Stop(); } - directoryEntry.Children = null; } - // Then remove the directory itself. - _wdToPathMap.Remove(directoryEntry.WatchDescriptor); - - // And if the caller has requested, remove the associated inotify watch. - if (removeInotify) + try { - // Remove the inotify watch. This could fail if our state has become inconsistent - // with the state of the world (e.g. due to lost events). So we don't want failures - // to throw exceptions, but we do assert to detect coding problems during debugging. - int result = Interop.Sys.INotifyRemoveWatch(_inotifyHandle, directoryEntry.WatchDescriptor); - Debug.Assert(result >= 0); - } - } + if (IsStopped) + { + return; + } - /// - /// Callback invoked when cancellation is requested. Removes all watches, - /// which will cause the active processing loop to shutdown. - /// - private void CancellationCallback() - { - lock (SyncObj) - { - // Remove all watches (inotiy_rm_watch) and clear out the map. - // No additional watches will be added after this point. - foreach (int wd in this._wdToPathMap.Keys) + // Carry over information from MOVED_FROM to MOVED_TO events. + int movedFromWatchCount = 0; + string movedFromName = ""; + uint movedFromCookie = 0; + bool movedFromIsDir = false; + + NotifyEvent nextEvent; + while (TryReadEvent(out nextEvent)) { - int result = Interop.Sys.INotifyRemoveWatch(_inotifyHandle, wd); - Debug.Assert(result >= 0); // ignore errors; they're non-fatal, but they also shouldn't happen + if (!ProcessEvent(nextEvent, ref movedFromWatchCount, ref movedFromName, ref movedFromCookie, ref movedFromIsDir)) + break; } + } + catch (Exception ex) + { + lock (_watchersLock) + { + Stop(); - _wdToPathMap.Clear(); + foreach (var watcher in _watchers) + { + watcher.QueueError(ex); + } + } + } + finally + { + Debug.Assert(IsStopped); } } - /// - /// Processes the next event. Method does not inline to prevent a strong reference to the watcher. - /// - /// The next event. - /// The previous event's name. - /// The previous event's parent. - /// The previous event's cookie. - /// if we can continue processing events, otherwise. - [MethodImpl(MethodImplOptions.NoInlining)] - private bool ProcessEvent(NotifyEvent nextEvent, ref ReadOnlySpan previousEventName, ref WatchedDirectory? previousEventParent, ref uint previousEventCookie) + private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, ref string movedFromName, ref uint movedFromCookie, ref bool movedFromIsDir) { - // Try to get the actual watcher from our weak reference. We maintain a weak reference most of the time - // so as to avoid a rooted cycle that would prevent our processing loop from ever ending - // if the watcher is dropped by the user without being disposed. If we can't get the watcher, - // there's nothing more to do (we can't raise events), so bail. - FileSystemWatcher? watcher; - if (!_weakWatcher.TryGetTarget(out watcher)) + // Subset of EventMask that are emitted conditionally based on NotifyFilters.DirectoryName/FileName. + const Interop.Sys.NotifyEvents FileDirEvents = + Interop.Sys.NotifyEvents.IN_CREATE | + Interop.Sys.NotifyEvents.IN_DELETE | + Interop.Sys.NotifyEvents.IN_MOVED_FROM | + Interop.Sys.NotifyEvents.IN_MOVED_TO; + // NotifyEvents that generate FileSystemWatcher events. + const Interop.Sys.NotifyEvents EventMask = + FileDirEvents | + Interop.Sys.NotifyEvents.IN_ACCESS | + Interop.Sys.NotifyEvents.IN_MODIFY | + Interop.Sys.NotifyEvents.IN_ATTRIB; + + Span pathBuffer = stackalloc char[PATH_MAX]; + Interop.Sys.NotifyEvents mask = (Interop.Sys.NotifyEvents)nextEvent.mask; + + // An overflow event means we missed events. + if ((mask & Interop.Sys.NotifyEvents.IN_Q_OVERFLOW) != 0) { + lock (_watchersLock) + { + Stop(); + + foreach (var watcher in _watchers) + { + watcher.QueueError(CreateBufferOverflowException(watcher.BasePath)); + watcher.Restart(); + } + } return false; } - uint mask = nextEvent.mask; - - // An overflow event means that we can't trust our state without restarting since we missed events and - // some of those events could be a directory create, meaning we wouldn't have added the directory to the - // watch and would not provide correct data to the caller. - if ((mask & (uint)Interop.Sys.NotifyEvents.IN_Q_OVERFLOW) != 0) + // Renames come in the form of two events: IN_MOVED_FROM and IN_MOVED_TO. + // These should come as a sequence, one immediately after the other. + // This holds the directories from the previous event in case it was IN_MOVED_FROM. + ReadOnlySpan movedFromDirs = _dirBuffer.AsSpan(0, movedFromWatchCount); + + // Look up the Watch in _wdToWatch. + // We take a writer lock to synchronize with AddOrUpdateWatchedDirectory and make sure newly added watch descriptors can be found in _wdToWatch. + _addLock.EnterWriteLock(); + _addLock.ExitWriteLock(); + _wdToWatch.TryGetValue(nextEvent.wd, out Watch? watch); + + // Watches for this event. + ReadOnlySpan dirs = watch is not null ? GetWatchedDirectories(watch, ref _dirBuffer, offset: movedFromDirs.Length) : default; + + // If the event after IN_MOVED_FROM is not a matching IN_MOVED_TO, we treat the IN_MOVED_FROM as a 'Deleted' in the next block. + // A matching IN_MOVED_TO will be handled as a 'Renamed' later on. + if (!movedFromDirs.IsEmpty) { - // Notify the caller of the error and, if the includeSubdirectories flag is set, restart to pick up any - // potential directories we missed due to the overflow. - watcher.NotifyInternalBufferOverflowEvent(); - if (_includeSubdirectories) + bool isMatchingMovedTo = (mask & Interop.Sys.NotifyEvents.IN_MOVED_TO) != 0 && movedFromCookie == nextEvent.cookie; + + foreach (var movedFrom in movedFromDirs) { - watcher.Restart(); + bool isRename = isMatchingMovedTo && FindMatchingWatchedDirectory(dirs, movedFrom.Watcher) is not null; + if (isRename) + { + continue; // Handled as a Rename. + } + + if (movedFromIsDir) + { + RemoveWatchedDirectoryChild(movedFrom, movedFromName); + } + + var watcher = movedFrom.Watcher; + if (!IsIgnoredEvent(watcher, Interop.Sys.NotifyEvents.IN_DELETE, movedFromIsDir)) + { + watcher.QueueEvent(WatcherEvent.Deleted(movedFrom, movedFromName)); + } + } + + if (!isMatchingMovedTo) + { + movedFromDirs = default; } - return false; } - // Look up the directory information for the supplied wd - WatchedDirectory? associatedDirectoryEntry = null; - lock (SyncObj) + // Determine whether the affected object is a directory (rather than a file). + // If it is, we may need to do special processing, such as adding a watch for new + // directories if IncludeSubdirectories is enabled. Since we're only watching + // directories, any IN_IGNORED event is also for a directory. + bool isDir = (mask & (Interop.Sys.NotifyEvents.IN_ISDIR | Interop.Sys.NotifyEvents.IN_IGNORED)) != 0; + + // For IN_MOVED_FROM we check if there is an event pending that may be a matching IN_MOVED_TO. + // If there is, we defer the handling to the next ProcessEvent. + // If there isn't, we'll handle it as a 'Deleted' later on. + if ((mask & Interop.Sys.NotifyEvents.IN_MOVED_FROM) != 0) { - if (!_wdToPathMap.TryGetValue(nextEvent.wd, out associatedDirectoryEntry)) + bool eventAvailable = _bufferPos != _bufferAvailable; + if (!eventAvailable) + { + // Do the poll with a small timeout value. Community research showed that a few milliseconds + // was enough to allow the vast majority of MOVED_TO events that were going to show + // up to actually arrive. This doesn't need to be perfect; there's always the chance + // that a MOVED_TO could show up after whatever timeout is specified, in which case + // it'll just result in a delete + create instead of a rename. We need the value to be + // small so that we don't significantly delay the delivery of the deleted event in case + // that's actually what's needed (otherwise it'd be fine to block indefinitely waiting + // for the next event to arrive). + const int MillisecondsTimeout = 2; + Interop.PollEvents events; + Interop.Sys.Poll(_inotifyHandle, Interop.PollEvents.POLLIN, MillisecondsTimeout, out events); + + eventAvailable = events != Interop.PollEvents.POLLNONE; + } + if (eventAvailable) { - // The watch descriptor could be missing from our dictionary if it was removed - // due to cancellation, or if we already removed it and this is a related event - // like IN_IGNORED. In any case, just ignore it... even if for some reason we - // should have the value, there's little we can do about it at this point, - // and there's no more processing of this event we can do without it. + movedFromName = nextEvent.name; + dirs.CopyTo(_dirBuffer); // dirs won't be at the start of _dirBuffer when movedFromWatchCount was not zero. + movedFromWatchCount = dirs.Length; + movedFromCookie = nextEvent.cookie; + movedFromIsDir = isDir; return true; } } + movedFromWatchCount = 0; - ReadOnlySpan expandedName = associatedDirectoryEntry.GetPath(true, nextEvent.name); - - // To match Windows, ignore all changes that happen on the root folder itself - if (expandedName.IsEmpty) + foreach (WatchedDirectory dir in dirs) { - return true; - } + Watcher watcher = dir.Watcher; - // Determine whether the affected object is a directory (rather than a file). - // If it is, we may need to do special processing, such as adding a watch for new - // directories if IncludeSubdirectories is enabled. Since we're only watching - // directories, any IN_IGNORED event is also for a directory. - bool isDir = (mask & (uint)(Interop.Sys.NotifyEvents.IN_ISDIR | Interop.Sys.NotifyEvents.IN_IGNORED)) != 0; + WatchedDirectory? matchingFrom = (mask & Interop.Sys.NotifyEvents.IN_MOVED_TO) != 0 ? FindMatchingWatchedDirectory(movedFromDirs, watcher) : null; - // Renames come in the form of two events: IN_MOVED_FROM and IN_MOVED_TO. - // In general, these should come as a sequence, one immediately after the other. - // So, we delay raising an event for IN_MOVED_FROM until we see what comes next. - if (!previousEventName.IsEmpty && ((mask & (uint)Interop.Sys.NotifyEvents.IN_MOVED_TO) == 0 || previousEventCookie != nextEvent.cookie)) - { - // IN_MOVED_FROM without an immediately-following corresponding IN_MOVED_TO. - // We have to assume that it was moved outside of our root watch path, which - // should be considered a deletion to match Win32 behavior. - // But since we explicitly added watches on directories, if it's a directory it'll - // still be watched, so we need to explicitly remove the watch. - if (previousEventParent != null && previousEventParent.Children != null) - { - // previousEventParent will be non-null iff the IN_MOVED_FROM - // was for a directory, in which case previousEventParent is that directory's - // parent and previousEventName is the name of the directory to be removed. - foreach (WatchedDirectory child in previousEventParent.Children) - { - if (previousEventName.Equals(child.Name, StringComparison.Ordinal)) + if (isDir && watcher.IncludeSubdirectories) + { + if ((mask & (Interop.Sys.NotifyEvents.IN_CREATE | Interop.Sys.NotifyEvents.IN_MOVED_TO)) != 0) + { + // If this is a rename, move over the watches from the source. + // We'll still call WatchChildDirectories in case the source was still being iterated for adding watches. + if (matchingFrom is not null) { - RemoveWatchedDirectory(child); - return false; + RenameWatchedDirectoryes(dir, nextEvent.name, matchingFrom, movedFromName); } + + string directoryPath = dir.GetPath(nextEvent.name, pathBuffer, fullPath: true).ToString(); + watcher.WatchChildDirectories(parent: dir, directoryPath); + } + else if ((mask & Interop.Sys.NotifyEvents.IN_MOVED_FROM) != 0) + { + RemoveWatchedDirectoryChild(dir, nextEvent.name); } } + // IN_IGNORED: Watch was removed explicitly or automatically because the directory was deleted. + if ((mask & Interop.Sys.NotifyEvents.IN_IGNORED) != 0) + { + // Stop tracking the watcher when its RootDirectory gets ignored. + // When we're watching no more root directories, we can stop. + if (dir.Parent is null) + { + lock (_watchersLock) + { + _watchers.Remove(dir.Watcher); - // Then fire the deletion event, even though the event was IN_MOVED_FROM. - watcher.NotifyFileSystemEventArgs(WatcherChangeTypes.Deleted, previousEventName); + if (_watchers.Count == 0) + { + Stop(); + return false; + } + } + } - previousEventName = null; - previousEventParent = null; - previousEventCookie = 0; - } + RemoveWatchedDirectory(dir, ignoredFd: nextEvent.wd); + continue; + } - // If the event signaled that there's a new subdirectory and if we're monitoring subdirectories, - // add a watch for it. - const Interop.Sys.NotifyEvents AddMaskFilters = Interop.Sys.NotifyEvents.IN_CREATE | Interop.Sys.NotifyEvents.IN_MOVED_TO; - bool addWatch = ((mask & (uint)AddMaskFilters) != 0); - if (addWatch && isDir && _includeSubdirectories) - { - AddDirectoryWatch(associatedDirectoryEntry, nextEvent.name); - } + // To match Windows, don't emit events for the root directory. + if (dir.Parent is null && nextEvent.name.Length == 0) + { + continue; + } - // Check if the event should have been filtered but was unable because of inotify's inability - // to filter files vs directories. - const Interop.Sys.NotifyEvents fileDirEvents = Interop.Sys.NotifyEvents.IN_CREATE | - Interop.Sys.NotifyEvents.IN_DELETE | - Interop.Sys.NotifyEvents.IN_MOVED_FROM | - Interop.Sys.NotifyEvents.IN_MOVED_TO; - if ((((uint)fileDirEvents & mask) > 0) && - (isDir && ((_notifyFilters & NotifyFilters.DirectoryName) == 0) || - (!isDir && ((_notifyFilters & NotifyFilters.FileName) == 0)))) - { - return true; - } + if (IsIgnoredEvent(watcher, mask, isDir)) + { + continue; + } - const Interop.Sys.NotifyEvents switchMask = fileDirEvents | Interop.Sys.NotifyEvents.IN_IGNORED | - Interop.Sys.NotifyEvents.IN_ACCESS | Interop.Sys.NotifyEvents.IN_MODIFY | Interop.Sys.NotifyEvents.IN_ATTRIB; - switch ((Interop.Sys.NotifyEvents)(mask & (uint)switchMask)) - { - case Interop.Sys.NotifyEvents.IN_CREATE: - watcher.NotifyFileSystemEventArgs(WatcherChangeTypes.Created, expandedName); - break; - case Interop.Sys.NotifyEvents.IN_IGNORED: - // We're getting an IN_IGNORED because a directory watch was removed. - // and we're getting this far in our code because we still have an entry for it - // in our dictionary. So we want to clean up the relevant state, but not clean - // attempt to call back to inotify to remove the watches. - RemoveWatchedDirectory(associatedDirectoryEntry, removeInotify: false); - break; - case Interop.Sys.NotifyEvents.IN_DELETE: - watcher.NotifyFileSystemEventArgs(WatcherChangeTypes.Deleted, expandedName); - // We don't explicitly RemoveWatchedDirectory here, as that'll be handled - // by IN_IGNORED processing if this is a directory. - break; - case Interop.Sys.NotifyEvents.IN_ACCESS: - case Interop.Sys.NotifyEvents.IN_MODIFY: - case Interop.Sys.NotifyEvents.IN_ATTRIB: - watcher.NotifyFileSystemEventArgs(WatcherChangeTypes.Changed, expandedName); - break; - case Interop.Sys.NotifyEvents.IN_MOVED_FROM: - // We need to check if this MOVED_FROM event is standalone - meaning the item was moved out - // of scope. We do this by checking if we are at the end of our buffer (meaning no more events) - // and if there is data to be read by polling the fd. If there aren't any more events, fire the - // deleted event; if there are more events, handle it via next pass. This adds an additional - // edge case where we get the MOVED_FROM event and the MOVED_TO event hasn't been generated yet - // so we will send a DELETE for this event and a CREATE when the MOVED_TO is eventually processed. - if (_bufferPos == _bufferAvailable) - { - // Do the poll with a small timeout value. Community research showed that a few milliseconds - // was enough to allow the vast majority of MOVED_TO events that were going to show - // up to actually arrive. This doesn't need to be perfect; there's always the chance - // that a MOVED_TO could show up after whatever timeout is specified, in which case - // it'll just result in a delete + create instead of a rename. We need the value to be - // small so that we don't significantly delay the delivery of the deleted event in case - // that's actually what's needed (otherwise it'd be fine to block indefinitely waiting - // for the next event to arrive). - const int MillisecondsTimeout = 2; - Interop.PollEvents events; - Interop.Sys.Poll(_inotifyHandle, Interop.PollEvents.POLLIN, MillisecondsTimeout, out events); - - // If we error or don't have any signaled handles, send the deleted event - if (events == Interop.PollEvents.POLLNONE) + switch (mask & EventMask) + { + case Interop.Sys.NotifyEvents.IN_CREATE: + watcher.QueueEvent(WatcherEvent.Created(dir, nextEvent.name)); + break; + case Interop.Sys.NotifyEvents.IN_DELETE: + watcher.QueueEvent(WatcherEvent.Deleted(dir, nextEvent.name)); + break; + case Interop.Sys.NotifyEvents.IN_ACCESS: + case Interop.Sys.NotifyEvents.IN_MODIFY: + case Interop.Sys.NotifyEvents.IN_ATTRIB: + watcher.QueueEvent(WatcherEvent.Changed(dir, nextEvent.name)); + break; + case Interop.Sys.NotifyEvents.IN_MOVED_FROM: + watcher.QueueEvent(WatcherEvent.Deleted(dir, nextEvent.name)); + break; + case Interop.Sys.NotifyEvents.IN_MOVED_TO: + if (matchingFrom is not null) { - // There isn't any more data in the queue so this is a deleted event - watcher.NotifyFileSystemEventArgs(WatcherChangeTypes.Deleted, expandedName); - break; + watcher.QueueEvent(WatcherEvent.Renamed(dir, nextEvent.name, matchingFrom, movedFromName)); } - } + else + { + watcher.QueueEvent(WatcherEvent.Created(dir, nextEvent.name)); + } + break; + } + } - // We will set these values if the buffer has more data OR if the poll call tells us that more data is available. - previousEventName = expandedName; - previousEventParent = isDir ? associatedDirectoryEntry : null; - previousEventCookie = nextEvent.cookie; + return true; - break; - case Interop.Sys.NotifyEvents.IN_MOVED_TO: - if (!previousEventName.IsEmpty) + static ReadOnlySpan GetWatchedDirectories(Watch watch, ref WatchedDirectory[] buffer, int offset) + { + lock (watch) + { + int watchersCount = watch.Watchers.Count; + int lengthNeeded = watchersCount + offset; + if (lengthNeeded > buffer.Length) { - // If the previous name from IN_MOVED_FROM is non-empty, then this is a rename. - watcher.NotifyRenameEventArgs(WatcherChangeTypes.Renamed, expandedName, previousEventName); + Array.Resize(ref buffer, lengthNeeded); } - else + watch.Watchers.CopyTo(buffer.AsSpan(offset)); + return buffer.AsSpan(offset, watchersCount); + } + } + + static WatchedDirectory? FindMatchingWatchedDirectory(ReadOnlySpan dir, Watcher watcher) + { + foreach (var d in dir) + { + if (d.Watcher == watcher) { - // If it is null, then we didn't get an IN_MOVED_FROM (or we got it a long time - // ago and treated it as a deletion), in which case this is considered a creation. - watcher.NotifyFileSystemEventArgs(WatcherChangeTypes.Created, expandedName); + return d; } - previousEventName = ReadOnlySpan.Empty; - previousEventParent = null; - previousEventCookie = 0; - break; + } + + return null; } - return true; + void RemoveWatchedDirectoryChild(WatchedDirectory dir, string movedFromName) + { + Watcher watcher = dir.Watcher; + WatchedDirectory? child = null; + lock (watcher) + { + int idx = dir.FindChild(movedFromName); + if (idx != -1) + { + child = dir.Children![idx]; + } + } + if (child is not null) + { + RemoveWatchedDirectory(child); + } + } + + static bool IsIgnoredEvent(Watcher watcher, Interop.Sys.NotifyEvents mask, bool isDir) + { + return (watcher.WatchFilters & mask) == 0 || + ((mask & FileDirEvents) != 0) && + ((isDir && ((watcher.NotifyFilters & NotifyFilters.DirectoryName) == 0)) || + (!isDir && ((watcher.NotifyFilters & NotifyFilters.FileName) == 0))); + } } - /// - /// Main processing loop. This is currently implemented as a synchronous operation that continually - /// reads events and processes them... in the future, this could be changed to use asynchronous processing - /// if the impact of using a thread-per-FileSystemWatcher is too high. - /// - private void ProcessEvents() + private void RenameWatchedDirectoryes(WatchedDirectory moveTo, string moveToName, WatchedDirectory moveFrom, string moveFromName) { - // When cancellation is requested, clear out all watches. This should force any active or future reads - // on the inotify handle to return 0 bytes read immediately, allowing us to wake up from the blocking call - // and exit the processing loop and clean up. - var ctr = _cancellationToken.UnsafeRegister(obj => ((RunningInstance)obj!).CancellationCallback(), this); - try + WatchedDirectory? sourceToRemove = null; + + Watcher watcher = moveFrom.Watcher; + Debug.Assert(moveTo.Watcher == watcher); + lock (watcher) { - // Previous event information - ReadOnlySpan previousEventName = ReadOnlySpan.Empty; - WatchedDirectory? previousEventParent = null; - uint previousEventCookie = 0; + int sourceIdx = moveFrom.FindChild(moveFromName); + if (sourceIdx == -1) + { + // unexpected: source not found. + return; + } + WatchedDirectory source = moveFrom.Children![sourceIdx]; - // Process events as long as we're not canceled and there are more to read... - NotifyEvent nextEvent; - while (!_cancellationToken.IsCancellationRequested && TryReadEvent(out nextEvent)) + int dstIdx = moveTo.FindChild(moveToName); + if (dstIdx != -1) { - if (!ProcessEvent(nextEvent, ref previousEventName, ref previousEventParent, ref previousEventCookie)) - break; + // unexpected: the destination already exists. Leave it and stop watching the source. + sourceToRemove = source; } - } - catch (Exception exc) - { - if (_weakWatcher.TryGetTarget(out FileSystemWatcher? watcher)) + else { - watcher.OnError(new ErrorEventArgs(exc)); + // We'll re-use the Watches. + moveFrom.Children.RemoveAt(sourceIdx); + WatchedDirectory renamed = CreateWatchedDirectoryFrom(moveTo, source, moveToName); + moveTo.InitializedChildren.Add(renamed); } } - finally + + if (sourceToRemove is not null) { - ctr.Dispose(); - _inotifyHandle.Dispose(); + RemoveWatchedDirectory(sourceToRemove); + } + + static WatchedDirectory CreateWatchedDirectoryFrom(WatchedDirectory parent, WatchedDirectory src, string name) + { + Watcher watcher = src.Watcher; + Debug.Assert(Monitor.IsEntered(watcher)); + + WatchedDirectory newDir; + Watch watch = src.Watch; + lock (watch) + { + newDir = new WatchedDirectory(watch, watcher, name, parent); + watch.Watchers.Remove(src); + watch.Watchers.Add(newDir); + } + + if (src.Children is { } children) + { + foreach (var child in children) + { + newDir.InitializedChildren.Add(CreateWatchedDirectoryFrom(newDir, child, child.Name)); + } + } + + return newDir; } } - /// Read event from the inotify handle into the supplied event object. - /// The event object to be populated. - /// if event was read successfully, otherwise. private bool TryReadEvent(out NotifyEvent notifyEvent) { Debug.Assert(_buffer != null); @@ -893,105 +941,425 @@ private struct NotifyEvent internal string name; } - /// State associated with a watched directory. - private sealed class WatchedDirectory + internal struct WatcherEvent { - /// A StringBuilder cached on the current thread to avoid allocations when possible. - [ThreadStatic] - private static StringBuilder? t_builder; + public const WatcherChangeTypes ErrorType = WatcherChangeTypes.All; + + public string? Name { get; } + public WatchedDirectory? Directory { get; } + public string? OldName { get; } + public WatchedDirectory? OldDirectory { get; } + public Exception? Exception { get; } + public WatcherChangeTypes Type { get; } + + private WatcherEvent(WatcherChangeTypes type, WatchedDirectory watch, string name, WatchedDirectory? oldWatch = null, string? oldName = null) + { + Type = type; + Directory = watch; + Name = name; + OldDirectory = oldWatch; + OldName = oldName; + } + + private WatcherEvent(Exception exception) + { + Type = ErrorType; + Exception = exception; + } - /// The parent directory. - internal WatchedDirectory? Parent; + public static WatcherEvent Deleted(WatchedDirectory dir, string name) + => new WatcherEvent(WatcherChangeTypes.Deleted, dir, name); - /// The watch descriptor associated with this directory. - internal int WatchDescriptor; + public static WatcherEvent Created(WatchedDirectory dir, string name) + => new WatcherEvent(WatcherChangeTypes.Created, dir, name); - /// The filename of this directory. - internal string? Name; + public static WatcherEvent Changed(WatchedDirectory dir, string name) + => new WatcherEvent(WatcherChangeTypes.Changed, dir, name); - /// Child directories of this directory for which we added explicit watches. - internal List? Children; + public static WatcherEvent Renamed(WatchedDirectory dir, string name, WatchedDirectory oldDir, string oldName) + => new WatcherEvent(WatcherChangeTypes.Renamed, dir, name, oldDir, oldName); - /// Child directories of this directory for which we added explicit watches. This is the same as Children, but ensured to be initialized as non-null. - internal List InitializedChildren => Children ??= new List(); + public static WatcherEvent Error(Exception exception) + => new WatcherEvent(exception); - // PERF: Work is being done here proportionate to depth of watch directories. - // If this becomes a bottleneck, we'll need to come up with another mechanism - // for obtaining and keeping paths up to date, for example storing the full path - // in each WatchedDirectory node and recursively updating all children on a move, - // which we can do given that we store all children. For now we're not doing that - // because it's not a clear win: either you update all children recursively when - // a directory moves / is added, or you compute each name when an event occurs. - // The former is better if there are going to be lots of events causing lots - // of traversals to compute names, and the latter is better for big directory - // structures that incur fewer file events. + public ReadOnlySpan GetName(Span pathBuffer) + => Directory!.GetPath(Name, pathBuffer); - /// Gets the path of this directory. - /// Whether to get a path relative to the root directory being watched, or a full path. - /// An additional name to include in the path, relative to this directory. - /// The computed path. - internal string GetPath(bool relativeToRoot, string? additionalName = null) + public ReadOnlySpan GetOldName(Span pathBuffer) + => OldDirectory!.GetPath(OldName, pathBuffer); + } + + public sealed class Watcher + { + // Ignore links. + private static readonly EnumerationOptions ChildEnumerationOptions = + new() { RecurseSubdirectories = false, MatchType = MatchType.Win32, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = false }; + + /// + /// Weak reference to the associated watcher. A weak reference is used so that the FileSystemWatcher may be collected and finalized, + /// causing an active operation to be torn down. With a strong reference, a blocking read on the inotify handle will keep alive this + /// instance which will keep alive the FileSystemWatcher which will not be finalizable and thus which will never signal to the blocking + /// read to wake up in the event that the user neglects to stop raising events. + /// + private readonly WeakReference _weakFsw; + private readonly INotify _inotify; + private readonly Channel _eventQueue; + public string BasePath { get; } + public NotifyFilters NotifyFilters { get; } + public Interop.Sys.NotifyEvents WatchFilters { get; } + public bool IncludeSubdirectories { get; } + + public bool EmitEvents { get; set; } + public bool IsStopped { get; set; } + public WatchedDirectory? RootDirectory { get; set; } + + public Watcher(INotify inotify, FileSystemWatcher fsw) + { + _inotify = inotify; + _weakFsw = new WeakReference(fsw); + BasePath = System.IO.Path.TrimEndingDirectorySeparator(System.IO.Path.GetFullPath(fsw.Path)); + IncludeSubdirectories = fsw.IncludeSubdirectories; + NotifyFilters = fsw.NotifyFilter; + WatchFilters = TranslateFilters(NotifyFilters); + _eventQueue = Channel.CreateUnbounded(new UnboundedChannelOptions() { AllowSynchronousContinuations = false, SingleReader = true }); + } + + internal bool CreateRootWatch() { - // Use our cached builder - StringBuilder builder = (t_builder ??= new StringBuilder()); - builder.Clear(); + RootDirectory = _inotify.AddOrUpdateWatchedDirectory(this, parent: null, BasePath, WatchFilters, followLinks: true, ignoreMissing: false); - // Write the directory's path. Then if an additional filename was supplied, append it - Write(builder, relativeToRoot); - if (additionalName != null) + bool hasRootWatch = RootDirectory is not null; + + if (hasRootWatch) { - AppendSeparatorIfNeeded(builder); - builder.Append(additionalName); + DequeueEvents(); } - return builder.ToString(); + + return hasRootWatch; } - /// Write's this directory's path to the builder. - /// The builder to which to write. - /// - /// true if the path should be relative to the root directory being watched. - /// false if the path should be a full file system path, including that of - /// the root directory being watched. - /// - private void Write(StringBuilder builder, bool relativeToRoot) + internal void Start() { - // This method is recursive. If we expect to see hierarchies - // so deep that it would cause us to overflow the stack, we could - // consider using an explicit stack object rather than recursion. - // This is unlikely, however, given typical directory names - // and max path limits. + if (RootDirectory is { } dir && IncludeSubdirectories) + { + if (IncludeSubdirectories) + { + WatchChildDirectories(dir, BasePath, includeBasePath: false); + } + } - // First append the parent's path - if (Parent != null) + EmitEvents = true; + } + + private async void DequeueEvents() + { + char[] pathBuffer = new char[PATH_MAX]; + try + { + await foreach (WatcherEvent evnt in _eventQueue.Reader.ReadAllAsync().ConfigureAwait(false)) + { + EmitEvent(evnt, pathBuffer); + } + } + catch (Exception ex) { - Parent.Write(builder, relativeToRoot); - AppendSeparatorIfNeeded(builder); + Fsw?.OnError(new ErrorEventArgs(ex)); } + Stop(); + } - // Then append ours. In the case of the root directory - // being watched, we only append its name if the caller - // has asked for a full path. - if (Parent != null || !relativeToRoot) + private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) + { + FileSystemWatcher? fsw = Fsw; + if (fsw is null) { - builder.Append(Name); + return; + } + + switch (evnt.Type) + { + case WatcherEvent.ErrorType: + fsw.OnError(new ErrorEventArgs(evnt.Exception!)); + break; + case WatcherChangeTypes.Created: + case WatcherChangeTypes.Deleted: + case WatcherChangeTypes.Changed: + { + ReadOnlySpan name = evnt.GetName(pathBuffer); + fsw.NotifyFileSystemEventArgs(evnt.Type, name); + } + break; + case WatcherChangeTypes.Renamed: + { + string name = evnt.GetName(pathBuffer).ToString(); + ReadOnlySpan oldName = evnt.GetOldName(pathBuffer); + fsw.NotifyRenameEventArgs(WatcherChangeTypes.Renamed, name, oldName); + } + break; + } + } + + internal void Restart() + { + Debug.Assert(_inotify.IsStopped); + + lock (this) + { + if (IsStopped) + { + return; + } + + // This will call Stop. + // Because our INotify instance is stopped, the Fsw will restart against a new INotify instance. + Fsw?.Restart(); + } + } + + internal void Stop() + { + WatchedDirectory? root; + lock (this) + { + if (IsStopped) + { + return; + } + IsStopped = true; + EmitEvents = false; + + root = RootDirectory; + } + + _eventQueue.Writer.Complete(); + + if (root is not null) + { + _inotify.RemoveWatchedDirectory(root); } } - /// Adds a directory path separator to the end of the builder if one isn't there. - /// The builder. - private static void AppendSeparatorIfNeeded(StringBuilder builder) + public bool WatchChildDirectories(WatchedDirectory parent, string path, bool includeBasePath = true) { - if (builder.Length > 0) + if (IsStopped) + { + return false; + } + + if (includeBasePath) + { + WatchedDirectory? newParent = AddOrUpdateWatch(parent, path); + if (newParent is null) + { + // We couldn't recurse this path, but we should continue to try the others. + return true; + } + parent = newParent; + } + + try { - char c = builder[builder.Length - 1]; - if (c != System.IO.Path.DirectorySeparatorChar && c != System.IO.Path.AltDirectorySeparatorChar) + foreach (var childDir in Directory.GetDirectories(path, "*", ChildEnumerationOptions)) { - builder.Append(System.IO.Path.DirectorySeparatorChar); + if (!WatchChildDirectories(parent, childDir)) + { + return false; + } } } + catch (DirectoryNotFoundException) + { } // path was removed + catch (IOException ex) when (ex.HResult == Interop.Error.ENOTDIR.Info().RawErrno) + { } // path was replaced by a file. + catch (Exception ex) + { + QueueError(ex); + } + + return true; + + WatchedDirectory? AddOrUpdateWatch(WatchedDirectory parent, string path) + => _inotify.AddOrUpdateWatchedDirectory(this, parent, path, WatchFilters, followLinks: false, ignoreMissing: true); + } + + internal void QueueEvent(WatcherEvent ev) + { + Debug.Assert(ev.Type != WatcherEvent.ErrorType); + if (!EmitEvents) + { + return; + } + _eventQueue.Writer.TryWrite(ev); + } + + internal void QueueError(Exception exception) + { + if (IsStopped) + { + return; + } + _eventQueue.Writer.TryWrite(WatcherEvent.Error(exception)); + } + + private FileSystemWatcher? Fsw + { + get + { + _weakFsw.TryGetTarget(out FileSystemWatcher? watcher); + return watcher; + } + } + + /// + /// Maps the FileSystemWatcher's NotifyFilters enumeration to the + /// corresponding Interop.Sys.NotifyEvents values. + /// + /// The filters provided the by user. + /// The corresponding NotifyEvents values to use with inotify. + private static Interop.Sys.NotifyEvents TranslateFilters(NotifyFilters filters) + { + Interop.Sys.NotifyEvents result = 0; + + // For the Created and Deleted events, we need to always + // register for the created/deleted inotify events, regardless + // of the supplied filters values. We explicitly don't include IN_DELETE_SELF. + // The Windows implementation doesn't include notifications for the root directory, + // and having this for subdirectories results in duplicate notifications, one from + // the parent and one from self. + result |= + Interop.Sys.NotifyEvents.IN_CREATE | + Interop.Sys.NotifyEvents.IN_DELETE; + + // For the Changed event, which inotify events we subscribe to + // are based on the NotifyFilters supplied. + const NotifyFilters filtersForAccess = + NotifyFilters.LastAccess; + const NotifyFilters filtersForModify = + NotifyFilters.LastAccess | + NotifyFilters.LastWrite | + NotifyFilters.Security | + NotifyFilters.Size; + const NotifyFilters filtersForAttrib = + NotifyFilters.Attributes | + NotifyFilters.CreationTime | + NotifyFilters.LastAccess | + NotifyFilters.LastWrite | + NotifyFilters.Security | + NotifyFilters.Size; + if ((filters & filtersForAccess) != 0) + { + result |= Interop.Sys.NotifyEvents.IN_ACCESS; + } + if ((filters & filtersForModify) != 0) + { + result |= Interop.Sys.NotifyEvents.IN_MODIFY; + } + if ((filters & filtersForAttrib) != 0) + { + result |= Interop.Sys.NotifyEvents.IN_ATTRIB; + } + + // For the Rename event, we'll register for the corresponding move inotify events if the + // caller's NotifyFilters asks for notifications related to names. + const NotifyFilters filtersForMoved = + NotifyFilters.FileName | + NotifyFilters.DirectoryName; + if ((filters & filtersForMoved) != 0) + { + result |= + Interop.Sys.NotifyEvents.IN_MOVED_FROM | + Interop.Sys.NotifyEvents.IN_MOVED_TO; + } + + return result; } } - } + internal sealed class WatchedDirectory + { + public Watch Watch { get; } + public Watcher Watcher { get; } + public string Name { get; } + public WatchedDirectory? Parent { get; } + + public WatchedDirectory(Watch watch, Watcher watcher, string name, WatchedDirectory? parent) + { + Watch = watch; + Watcher = watcher; + Name = name; + Parent = parent; + } + + public List? Children; + public List InitializedChildren => Children ??= new List(); + + public int FindChild(string name) + { + Debug.Assert(Monitor.IsEntered(Watcher)); + var children = Children; + if (children is null) + { + return -1; + } + for (int i = 0; i < children.Count; i++) + { + if (children[i].Name == name) + { + return i; + } + } + return -1; + } + + internal ReadOnlySpan GetPath(ReadOnlySpan childName, Span pathBuffer, bool fullPath = false) + { + int length = 0; + + if (Parent is not null) + { + length = Parent.GetPath("", pathBuffer, fullPath).Length; + fullPath = false; + } + + if (fullPath) + { + Append(pathBuffer, Watcher.BasePath); + } + + Append(pathBuffer, Name); + Append(pathBuffer, childName); + + return pathBuffer.Slice(0, length); + + void Append(Span pathBuffer, ReadOnlySpan path) + { + if (path.Length == 0) + { + return; + } + + if (length != 0 && pathBuffer[length - 1] != '/') + { + pathBuffer[length] = '/'; + length++; + } + + path.CopyTo(pathBuffer.Slice(length)); + length += path.Length; + } + } + } + + internal sealed class Watch + { + public int WatchDescriptor { get; } + public List Watchers { get; } = new(); + + public Watch(int watchDescriptor) + { + WatchDescriptor = watchDescriptor; + } + } + } } } diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.cs index f285b0572ccd4d..ceceb6289006f9 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.cs @@ -397,11 +397,13 @@ private void NotifyInternalBufferOverflowEvent() { if (_onErrorHandler != null) { - OnError(new ErrorEventArgs( - new InternalBufferOverflowException(SR.Format(SR.FSW_BufferOverflow, _directory)))); + OnError(new ErrorEventArgs(CreateBufferOverflowException(_directory))); } } + private static InternalBufferOverflowException CreateBufferOverflowException(string directoryPath) + => new InternalBufferOverflowException(SR.Format(SR.FSW_BufferOverflow, directoryPath)); + /// /// Raises the event to each handler in the list. /// From 3fbd3744dd33b9530617629c94fd264c39f51600 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Tue, 1 Jul 2025 09:24:39 +0200 Subject: [PATCH 02/26] Fix xunit test hang. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 170 +++++++++++------- 1 file changed, 104 insertions(+), 66 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index ea1de24d6c07db..632edd9bc68456 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -13,9 +13,35 @@ namespace System.IO { - // Note: This class has an OS Limitation where the inotify API can miss events if a directory is created and immediately has - // changes underneath. This is due to the inotify* APIs not being recursive and needing to call inotify_add_watch on - // each subdirectory, causing a race between adding the watch and file system events happening. + // Implementation notes: + // + // Missed events for recursive watching: + // The inotify APIs are not recursive. We need to call inotify_add_watch when we detect a child directory to track it. + // Events that occurred on the directory before we've added it will be lost. + // + // Path vs directory: + // Note that inotify does not watch a path, but it watches directories. + // When a path is passed to inotify_add_watch, the directory is looked up by the kernel and a watch descriptor (wd) is returned for watching that directory. + // If the directory is moved to a different path, inotify will continue to reports its events. + // If we have previously added a watch for a path, and we call inotify_add_watch again for that path then: + // - if the looked up directory is still the same, the same wd will be returned, or + // - if the path now refers to a different directory, another wd will be returned. + // + // For each FileSystemWatcher we use a Watcher object that represents all inotify operations performed for that FileSystemWatcher. + // To represent the difference explained above (path vs directory) we use a WatchDirectory object to represent a path that is watched + // and a separate Watch object that represent the wd returned by the inotify_add_watch. + // Each WatchDirectory has a single Watch, while a Watch may be used by several WatchDirectories. + // When there are no more WatchDirectories using the Watch, we can remove it. + // + // Locking: + // To prevent deadlocks, the locks (as needed) should be taken in this order: s_watchersLock, _addLock, lock on Watcher instance, lock on Watch instance. + // + // Shared inotify instance: + // By default, the number of inotify instances per user is limited to 128. + // Because of this low limit, we make all the FileSystemWatchers share a single inotify instance to reduce contention with other processes. + // A dedicated thread dequeues the inotify events. From the inotify events, FileSystemWatcher events are emitted from the ThreadPool. + // This stops FileSystemWatcher event handlers to block one another, or them blocking the inotify thread which could cause the inotify event queue to overflow. + // This requires us to use IN_MASK_ADD which may cause us to continue receive events that no FileSystemWatcher is still interested in. public partial class FileSystemWatcher { private const int PATH_MAX = 4096; @@ -78,37 +104,12 @@ private void FinalizeDispose() catch { return null; } } - // Implementation notes: - // - // Path vs directory: - // Note that inotify does not watch a path, but it watches directories. - // When a path is passed to inotify_add_watch, the directory is looked up by the kernel and a watch descriptor (wd) is returned for watching that directory. - // If the directory is moved to a different path, inotify will continue to reports its events. - // If we have previously added a watch for a path, and we call inotify_add_watch again for that path then: - // - if the looked up directory is still the same, the same wd will be returned, or - // - if the path now refers to a different directory, another wd will be returned. - // - // For each FileSystemWatcher we use a Watcher object that represents all inotify operations performed for that FileSystemWatcher. - // To represent the difference explained above (path vs directory) we use a WatchDirectory object to represent a path that is watched - // and a separate Watch object that represent the wd returned by the inotify_add_watch. - // Each WatchDirectory has a single Watch, while a Watch may be used by several WatchDirectories. - // When there are no more WatchDirectories using the Watch, we can remove it. - // - // Locking: - // To prevent deadlocks, the locks (as needed) should be taken in this order: _addLock/s_watchersLock, lock on Watcher instance, lock on Watch instance. - // - // Shared inotify instance: - // By default, the number of inotify instances per user is limited to 128. - // Because of this low limit, we make all the FileSystemWatchers share a single inotify instance to reduce contention with other processes. - // A dedicated thread dequeues the inotify events. From the inotify events, FileSystemWatcher events are emitted from the ThreadPool. - // This stops FileSystemWatcher event handlers to block one another, or them blocking the inotify thread which could cause the inotify event queue to overflow. - // This requires us to use IN_MASK_ADD which may cause us to continue receive events that no FileSystemWatcher is still interested in. private sealed class INotify { // Guards the watchers of the inotify instance. public static readonly object s_watchersLock = new(); - private static INotify? _currentInotify; + private static INotify? s_currentInotify; public static Watcher? StartWatcher(FileSystemWatcher fsw) { @@ -116,14 +117,14 @@ private sealed class INotify lock (s_watchersLock) { // If there is no running instance, start one. - if (_currentInotify is null || _currentInotify.IsStopped) + if (s_currentInotify is null || s_currentInotify.IsStopped) { INotify inotify = new(s_watchersLock); inotify.Start(); - _currentInotify = inotify; + s_currentInotify = inotify; } - watcher = _currentInotify.CreateWatcherCore(fsw); + watcher = s_currentInotify.CreateWatcherCore(fsw); } watcher.Start(); @@ -221,23 +222,16 @@ public void Start() private void Stop() { - // note: this method gets called only on the ProcessEvents thread, or when that thread fails to start. - Debug.Assert(Monitor.IsEntered(_watchersLock)); - Debug.Assert(!IsStopped); + // This method gets called only on the ProcessEvents thread, or when that thread fails to start. + // It closes the inotify handle. - IsStopped = true; + IsStopped = true; // note: may be set already by RemoveUnusedINotifyWatches. - // Before we can close the inotify handle we need to sync so no further watches may be added/removed: - // Sync with AddOrUpdateWatchedDirectory. - foreach (var watcher in _watchers) - { - lock (watcher) - { } - } - // Sync with RemoveUnusedINotifyWatches. + // Sync with AddOrUpdateWatchedDirectory and RemoveUnusedINotifyWatches. _addLock.EnterWriteLock(); _addLock.ExitWriteLock(); + // Close the handle. _inotifyHandle.Dispose(); } @@ -246,13 +240,14 @@ private void Stop() WatchedDirectory? inotifyWatchesToRemove = null; WatchedDirectory dir; + // This locks prevents removing watches while watches are being added. + // It is also used to synchronize with Stop. _addLock.EnterReadLock(); try { // Serialize adding watches to the same watcher. // Concurrently adding watches may happen during the initial reursive iteration of the directory. // This ensures the WatchedDirectory matches with the most recent INotifyAddWatch directory. - // The lock on the watcher is also used to synchronizes with Stop. lock (watcher) { if (IsStopped || watcher.IsStopped) @@ -376,19 +371,45 @@ public void RemoveWatchedDirectory(WatchedDirectory dir, int ignoredFd = -1) private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignoredFd = -1) { - // _addLock stops handles from being added while we'll removing watches. - // This is needed to prevent removing watch descriptors between INotifyAddWatch and adding them to the Watch.Watchers. - // _addLock is also used to synchronizes with Stop. - _addLock.EnterWriteLock(); + bool watchersLockTaken = false; + bool addLockTaken = false; try { + // If this is a root watch we need to take the watchers lock. + if (removedDir.Parent is null) + { + Monitor.Enter(_watchersLock, ref watchersLockTaken); + } + + // _addLock stops handles from being added while we'll removing watches. + // This is needed to prevent removing watch descriptors between INotifyAddWatch and adding them to the Watch.Watchers. + // _addLock is also used to synchronizes with Stop. + _addLock.EnterWriteLock(); + addLockTaken = true; + if (IsStopped) { return; } + if (watchersLockTaken) + { + _watchers.Remove(removedDir.Watcher); + + // We'll return the watch to get an IN_IGNORE event that will stop the event loop. + IsStopped = _watchers.Count == 0; + + Monitor.Exit(_watchersLock); + watchersLockTaken = false; + } + RemoveINotifyWatchWhenNoMoreWatchers(removedDir.Watch, ignoredFd); + if (IsStopped) + { + return; + } + if (removedDir.Children is { } children) { foreach (var child in children) @@ -399,7 +420,15 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored } finally { - _addLock.ExitWriteLock(); + if (addLockTaken) + { + _addLock.ExitWriteLock(); + } + + if (watchersLockTaken) + { + Monitor.Exit(_watchersLock); + } } void RemoveINotifyWatchWhenNoMoreWatchers(Watch watch, int ignoredFd) @@ -471,20 +500,16 @@ static void RemoveFromWatch(WatchedDirectory dir, int ignoredFd, ref bool remove private void ProcessEvents() { - lock (_watchersLock) - { - // We've started an INotify, but no root watch was created for the FileSystemWatcher that started it. - if (_watchers.Count == 0) - { - Stop(); - } - } - try { - if (IsStopped) + lock (_watchersLock) { - return; + // We've started an INotify, but no root watch was created for the FileSystemWatcher that started it. + if (_watchers.Count == 0) + { + Stop(); + return; + } } // Carry over information from MOVED_FROM to MOVED_TO events. @@ -514,7 +539,7 @@ private void ProcessEvents() } finally { - Debug.Assert(IsStopped); + Debug.Assert(_inotifyHandle.IsClosed); } } @@ -533,6 +558,13 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re Interop.Sys.NotifyEvents.IN_MODIFY | Interop.Sys.NotifyEvents.IN_ATTRIB; + // When RemoveUnusedINotifyWatches removes the last watcher, it sets IsStopped and we get an IN_IGNORE event. + if (IsStopped) + { + Stop(); + return false; + } + Span pathBuffer = stackalloc char[PATH_MAX]; Interop.Sys.NotifyEvents mask = (Interop.Sys.NotifyEvents)nextEvent.mask; @@ -1032,7 +1064,7 @@ internal bool CreateRootWatch() if (hasRootWatch) { - DequeueEvents(); + _ = DequeueEvents(); } return hasRootWatch; @@ -1051,7 +1083,7 @@ internal void Start() EmitEvents = true; } - private async void DequeueEvents() + private async Task DequeueEvents() { char[] pathBuffer = new char[PATH_MAX]; try @@ -1063,9 +1095,15 @@ private async void DequeueEvents() } catch (Exception ex) { - Fsw?.OnError(new ErrorEventArgs(ex)); + Stop(); + + try + { + Fsw?.OnError(new ErrorEventArgs(ex)); + } + catch + { } } - Stop(); } private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) From 07e86f396f0469ab536e9ee79cfcdcb0447d03c7 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Tue, 1 Jul 2025 09:59:03 +0200 Subject: [PATCH 03/26] csproj: try fix CI leg build failures by adding References in all non-Windows configurations. --- .../src/System.IO.FileSystem.Watcher.csproj | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj b/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj index 2c700efae9c48e..14cf4ba28609ef 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj @@ -133,9 +133,6 @@ - - - From 3cb75b023f945216f52ad70b76b6786b6473aa3d Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Tue, 1 Jul 2025 20:25:54 +0200 Subject: [PATCH 04/26] Don't consider ignoreFd in RemoveWatchedDirectoryFromParentAndWatches or we won't update _wdToWatch. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 632edd9bc68456..67cf4dec84b369 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -361,7 +361,7 @@ public void RemoveWatchedDirectory(WatchedDirectory dir, int ignoredFd = -1) { bool removeINotifyWatches = false; - RemoveWatchedDirectoryFromParentAndWatches(dir, ref removeINotifyWatches, ignoredFd); + RemoveWatchedDirectoryFromParentAndWatches(dir, ref removeINotifyWatches); if (removeINotifyWatches) { @@ -449,7 +449,7 @@ void RemoveINotifyWatchWhenNoMoreWatchers(Watch watch, int ignoredFd) } } - private static void RemoveWatchedDirectoryFromParentAndWatches(WatchedDirectory dir, ref bool removeINotifyWatches, int ignoredFd = -1) + private static void RemoveWatchedDirectoryFromParentAndWatches(WatchedDirectory dir, ref bool removeINotifyWatches) { Watcher watcher = dir.Watcher; lock (watcher) @@ -473,27 +473,24 @@ private static void RemoveWatchedDirectoryFromParentAndWatches(WatchedDirectory dir.Parent.Children!.RemoveAt(idx); } - RemoveFromWatch(dir, ignoredFd, ref removeINotifyWatches); + RemoveFromWatch(dir, ref removeINotifyWatches); if (dir.Children is { } children) { foreach (var child in children) { - RemoveFromWatch(child, ignoredFd, ref removeINotifyWatches); + RemoveFromWatch(child, ref removeINotifyWatches); } } } - static void RemoveFromWatch(WatchedDirectory dir, int ignoredFd, ref bool removeINotifyWatches) + static void RemoveFromWatch(WatchedDirectory dir, ref bool removeINotifyWatches) { Watch watch = dir.Watch; lock (watch) { watch.Watchers.Remove(dir); - if (watch.WatchDescriptor != ignoredFd) - { - removeINotifyWatches |= watch.Watchers.Count == 0; - } + removeINotifyWatches |= watch.Watchers.Count == 0; } } } @@ -715,6 +712,14 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re } RemoveWatchedDirectory(dir, ignoredFd: nextEvent.wd); + + // RemoveUnusedINotifyWatches sets IsStopped when the last watcher is removed. + if (IsStopped) + { + Stop(); + return false; + } + continue; } From 4d64e82aaf9c08b19127d390c71708ae324e1ece Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Wed, 2 Jul 2025 20:33:11 +0200 Subject: [PATCH 05/26] Explain why RemoveUnusedINotifyWatches takes the watchers lock. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 67cf4dec84b369..fb1b1bf2bf0fb7 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -28,9 +28,9 @@ namespace System.IO // - if the path now refers to a different directory, another wd will be returned. // // For each FileSystemWatcher we use a Watcher object that represents all inotify operations performed for that FileSystemWatcher. - // To represent the difference explained above (path vs directory) we use a WatchDirectory object to represent a path that is watched + // To represent the difference explained above (path vs directory) we use a WatchedDirectory object to represent a path that is watched // and a separate Watch object that represent the wd returned by the inotify_add_watch. - // Each WatchDirectory has a single Watch, while a Watch may be used by several WatchDirectories. + // Each WatchedDirectory has a single Watch, while a Watch may be used by several WatchDirectories. // When there are no more WatchDirectories using the Watch, we can remove it. // // Locking: @@ -375,8 +375,10 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored bool addLockTaken = false; try { - // If this is a root watch we need to take the watchers lock. - if (removedDir.Parent is null) + // If the user sets EnableRaisingEvents to false on the last Watcher, + // instead of removing all the inotify watches, we'll only remove the root watch (to trigger an IN_IGNORED) + // and then ProcessEvents will stop when it sees IsStopped was set. + if (ignoredFd == -1 && removedDir.IsRootDir) { Monitor.Enter(_watchersLock, ref watchersLockTaken); } @@ -454,7 +456,7 @@ private static void RemoveWatchedDirectoryFromParentAndWatches(WatchedDirectory Watcher watcher = dir.Watcher; lock (watcher) { - if (dir.Parent is null) + if (dir.IsRootDir) { if (watcher.RootDirectory == null) { @@ -464,6 +466,7 @@ private static void RemoveWatchedDirectoryFromParentAndWatches(WatchedDirectory } else { + Debug.Assert(dir.Parent is not null); // !IsRootDirectory int idx = dir.Parent.FindChild(dir.Name); Debug.Assert(idx != -1); if (idx == -1) @@ -697,7 +700,7 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re { // Stop tracking the watcher when its RootDirectory gets ignored. // When we're watching no more root directories, we can stop. - if (dir.Parent is null) + if (dir.IsRootDir) { lock (_watchersLock) { @@ -724,7 +727,7 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re } // To match Windows, don't emit events for the root directory. - if (dir.Parent is null && nextEvent.name.Length == 0) + if (dir.IsRootDir && nextEvent.name.Length == 0) { continue; } @@ -1324,6 +1327,7 @@ internal sealed class WatchedDirectory public Watcher Watcher { get; } public string Name { get; } public WatchedDirectory? Parent { get; } + public bool IsRootDir => Parent is null; public WatchedDirectory(Watch watch, Watcher watcher, string name, WatchedDirectory? parent) { From 2941f9f05a0b858025c8e6a08c08855495b9c1cd Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Wed, 2 Jul 2025 21:09:42 +0200 Subject: [PATCH 06/26] Detect when the watchers dropped to zero in a single place. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index fb1b1bf2bf0fb7..b8e9410fe84ee2 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -375,10 +375,10 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored bool addLockTaken = false; try { - // If the user sets EnableRaisingEvents to false on the last Watcher, - // instead of removing all the inotify watches, we'll only remove the root watch (to trigger an IN_IGNORED) - // and then ProcessEvents will stop when it sees IsStopped was set. - if (ignoredFd == -1 && removedDir.IsRootDir) + // If we're removing the root directory of the last watcher, + // we'll only remove the root watch (to trigger an IN_IGNORED if we got called through EnableRaisingEvents = false) + // and ProcessEvents will stop when it sees IsStopped was set. + if (removedDir.IsRootDir) { Monitor.Enter(_watchersLock, ref watchersLockTaken); } @@ -698,22 +698,6 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re // IN_IGNORED: Watch was removed explicitly or automatically because the directory was deleted. if ((mask & Interop.Sys.NotifyEvents.IN_IGNORED) != 0) { - // Stop tracking the watcher when its RootDirectory gets ignored. - // When we're watching no more root directories, we can stop. - if (dir.IsRootDir) - { - lock (_watchersLock) - { - _watchers.Remove(dir.Watcher); - - if (_watchers.Count == 0) - { - Stop(); - return false; - } - } - } - RemoveWatchedDirectory(dir, ignoredFd: nextEvent.wd); // RemoveUnusedINotifyWatches sets IsStopped when the last watcher is removed. From 3d91deec488bfe383a4f68e7c054a6d84cd95359 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Wed, 2 Jul 2025 22:03:54 +0200 Subject: [PATCH 07/26] Check if watchers didn't drop to zero on every IN_IGNORED. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index b8e9410fe84ee2..7e3f826b8a14bf 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -376,8 +376,8 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored try { // If we're removing the root directory of the last watcher, - // we'll only remove the root watch (to trigger an IN_IGNORED if we got called through EnableRaisingEvents = false) - // and ProcessEvents will stop when it sees IsStopped was set. + // we'll only remove the root watch (to potentially trigger an IN_IGNORED if we got called through EnableRaisingEvents = false) + // and let the other watches get cleaned up when the inotify gets closed. if (removedDir.IsRootDir) { Monitor.Enter(_watchersLock, ref watchersLockTaken); @@ -398,7 +398,7 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored { _watchers.Remove(removedDir.Watcher); - // We'll return the watch to get an IN_IGNORE event that will stop the event loop. + // Mark us as IsStopped to prevent new watchers from being added. IsStopped = _watchers.Count == 0; Monitor.Exit(_watchersLock); @@ -558,13 +558,6 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re Interop.Sys.NotifyEvents.IN_MODIFY | Interop.Sys.NotifyEvents.IN_ATTRIB; - // When RemoveUnusedINotifyWatches removes the last watcher, it sets IsStopped and we get an IN_IGNORE event. - if (IsStopped) - { - Stop(); - return false; - } - Span pathBuffer = stackalloc char[PATH_MAX]; Interop.Sys.NotifyEvents mask = (Interop.Sys.NotifyEvents)nextEvent.mask; @@ -699,14 +692,6 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re if ((mask & Interop.Sys.NotifyEvents.IN_IGNORED) != 0) { RemoveWatchedDirectory(dir, ignoredFd: nextEvent.wd); - - // RemoveUnusedINotifyWatches sets IsStopped when the last watcher is removed. - if (IsStopped) - { - Stop(); - return false; - } - continue; } @@ -750,6 +735,20 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re } } + // For each Watcher there is a root watch that will generate an IN_IGNORED when it is a removed. + // Check if we can stop because all Watchers were removed. + if ((mask & Interop.Sys.NotifyEvents.IN_IGNORED) != 0) + { + lock (_watchersLock) + { + if (_watchers.Count == 0) + { + Stop(); + return false; + } + } + } + return true; static ReadOnlySpan GetWatchedDirectories(Watch watch, ref WatchedDirectory[] buffer, int offset) From 41546fba31abcb31616f571da4f6181f55195830 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Thu, 3 Jul 2025 07:06:08 +0200 Subject: [PATCH 08/26] Fix and simplify how we detect when all watchers stopped. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 91 +++++++------------ 1 file changed, 34 insertions(+), 57 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 7e3f826b8a14bf..70082716aa2f28 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -145,6 +145,7 @@ private sealed class INotify private readonly SafeFileHandle _inotifyHandle; private readonly ConcurrentDictionary _wdToWatch = new ConcurrentDictionary(); private readonly ReaderWriterLockSlim _addLock = new(LockRecursionPolicy.NoRecursion); + private bool _allWatchersStopped; private int _bufferAvailable; private int _bufferPos; @@ -224,8 +225,8 @@ private void Stop() { // This method gets called only on the ProcessEvents thread, or when that thread fails to start. // It closes the inotify handle. - - IsStopped = true; // note: may be set already by RemoveUnusedINotifyWatches. + Debug.Assert(!IsStopped); + IsStopped = true; // Sync with AddOrUpdateWatchedDirectory and RemoveUnusedINotifyWatches. _addLock.EnterWriteLock(); @@ -371,66 +372,36 @@ public void RemoveWatchedDirectory(WatchedDirectory dir, int ignoredFd = -1) private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignoredFd = -1) { - bool watchersLockTaken = false; - bool addLockTaken = false; + // _addLock stops handles from being added while we'll removing watches. + // This is needed to prevent removing watch descriptors between INotifyAddWatch and adding them to the Watch.Watchers. + // _addLock is also used to synchronizes with Stop. + _addLock.EnterWriteLock(); try { - // If we're removing the root directory of the last watcher, - // we'll only remove the root watch (to potentially trigger an IN_IGNORED if we got called through EnableRaisingEvents = false) - // and let the other watches get cleaned up when the inotify gets closed. - if (removedDir.IsRootDir) - { - Monitor.Enter(_watchersLock, ref watchersLockTaken); - } - - // _addLock stops handles from being added while we'll removing watches. - // This is needed to prevent removing watch descriptors between INotifyAddWatch and adding them to the Watch.Watchers. - // _addLock is also used to synchronizes with Stop. - _addLock.EnterWriteLock(); - addLockTaken = true; - if (IsStopped) { return; } - if (watchersLockTaken) - { - _watchers.Remove(removedDir.Watcher); - - // Mark us as IsStopped to prevent new watchers from being added. - IsStopped = _watchers.Count == 0; - - Monitor.Exit(_watchersLock); - watchersLockTaken = false; - } - RemoveINotifyWatchWhenNoMoreWatchers(removedDir.Watch, ignoredFd); - if (IsStopped) + // We don't need to remove the children when all watchers have stopped and the inotify will be closed. + if (_allWatchersStopped) { return; } if (removedDir.Children is { } children) - { - foreach (var child in children) { - RemoveINotifyWatchWhenNoMoreWatchers(child.Watch, ignoredFd); + foreach (var child in children) + { + RemoveINotifyWatchWhenNoMoreWatchers(child.Watch, ignoredFd); + } } - } } finally { - if (addLockTaken) - { - _addLock.ExitWriteLock(); - } - - if (watchersLockTaken) - { - Monitor.Exit(_watchersLock); - } + _addLock.ExitWriteLock(); } void RemoveINotifyWatchWhenNoMoreWatchers(Watch watch, int ignoredFd) @@ -451,8 +422,19 @@ void RemoveINotifyWatchWhenNoMoreWatchers(Watch watch, int ignoredFd) } } - private static void RemoveWatchedDirectoryFromParentAndWatches(WatchedDirectory dir, ref bool removeINotifyWatches) + private void RemoveWatchedDirectoryFromParentAndWatches(WatchedDirectory dir, ref bool removeINotifyWatches) { + if (dir.IsRootDir) + { + lock (s_watchersLock) + { + _watchers.Remove(dir.Watcher); + + // Set _allWatchersStopped before we update the Watch and _wdToWatch. + _allWatchersStopped = _watchers.Count == 0; + } + } + Watcher watcher = dir.Watcher; lock (watcher) { @@ -677,7 +659,7 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re // We'll still call WatchChildDirectories in case the source was still being iterated for adding watches. if (matchingFrom is not null) { - RenameWatchedDirectoryes(dir, nextEvent.name, matchingFrom, movedFromName); + RenameWatchedDirectories(dir, nextEvent.name, matchingFrom, movedFromName); } string directoryPath = dir.GetPath(nextEvent.name, pathBuffer, fullPath: true).ToString(); @@ -735,18 +717,13 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re } } - // For each Watcher there is a root watch that will generate an IN_IGNORED when it is a removed. - // Check if we can stop because all Watchers were removed. - if ((mask & Interop.Sys.NotifyEvents.IN_IGNORED) != 0) + // For each Watcher we'll receive an IN_IGNORED for its root watch. + // If the root watch was found back as a WatchedDirectory via _wdToWatch above, then _allWatchersStopped will be updated by calling RemoveWatchedDirectory. + // If we didn't find back the WatchedDirectory, then RemoveWatchedDirectory was called already and it has updated _allWatchersStopped. + if (_allWatchersStopped) { - lock (_watchersLock) - { - if (_watchers.Count == 0) - { - Stop(); - return false; - } - } + Stop(); + return false; } return true; @@ -806,7 +783,7 @@ static bool IsIgnoredEvent(Watcher watcher, Interop.Sys.NotifyEvents mask, bool } } - private void RenameWatchedDirectoryes(WatchedDirectory moveTo, string moveToName, WatchedDirectory moveFrom, string moveFromName) + private void RenameWatchedDirectories(WatchedDirectory moveTo, string moveToName, WatchedDirectory moveFrom, string moveFromName) { WatchedDirectory? sourceToRemove = null; From 680c50aed46c77fd145e28d719658c0aa7579b0e Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Thu, 3 Jul 2025 07:21:06 +0200 Subject: [PATCH 09/26] Don't accept new Watchers when all watchers stopped. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 70082716aa2f28..fbaaf94b202833 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -137,7 +137,7 @@ private sealed class INotify /// private const int c_INotifyEventSize = 16; - public bool IsStopped { get; private set; } + public bool IsStopped => _isClosingHandle || _allWatchersStopped; private readonly object _watchersLock; private readonly List _watchers = new(); @@ -145,6 +145,7 @@ private sealed class INotify private readonly SafeFileHandle _inotifyHandle; private readonly ConcurrentDictionary _wdToWatch = new ConcurrentDictionary(); private readonly ReaderWriterLockSlim _addLock = new(LockRecursionPolicy.NoRecursion); + private bool _isClosingHandle; private bool _allWatchersStopped; private int _bufferAvailable; @@ -225,8 +226,8 @@ private void Stop() { // This method gets called only on the ProcessEvents thread, or when that thread fails to start. // It closes the inotify handle. - Debug.Assert(!IsStopped); - IsStopped = true; + Debug.Assert(!_isClosingHandle); + _isClosingHandle = true; // Sync with AddOrUpdateWatchedDirectory and RemoveUnusedINotifyWatches. _addLock.EnterWriteLock(); @@ -251,7 +252,7 @@ private void Stop() // This ensures the WatchedDirectory matches with the most recent INotifyAddWatch directory. lock (watcher) { - if (IsStopped || watcher.IsStopped) + if (_isClosingHandle || watcher.IsStopped) { return null; } @@ -378,7 +379,7 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored _addLock.EnterWriteLock(); try { - if (IsStopped) + if (_isClosingHandle) { return; } @@ -1107,7 +1108,7 @@ private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) internal void Restart() { - Debug.Assert(_inotify.IsStopped); + Debug.Assert(_inotify._isClosingHandle); lock (this) { From eeab032a944256656ae27940d30f1dce31b4af9d Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Thu, 3 Jul 2025 07:29:21 +0200 Subject: [PATCH 10/26] Rename variable. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index fbaaf94b202833..7bfed4362d9aad 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -137,7 +137,7 @@ private sealed class INotify /// private const int c_INotifyEventSize = 16; - public bool IsStopped => _isClosingHandle || _allWatchersStopped; + public bool IsStopped => _isProcessThreadStopping || _allWatchersStopped; private readonly object _watchersLock; private readonly List _watchers = new(); @@ -145,7 +145,7 @@ private sealed class INotify private readonly SafeFileHandle _inotifyHandle; private readonly ConcurrentDictionary _wdToWatch = new ConcurrentDictionary(); private readonly ReaderWriterLockSlim _addLock = new(LockRecursionPolicy.NoRecursion); - private bool _isClosingHandle; + private bool _isProcessThreadStopping; private bool _allWatchersStopped; private int _bufferAvailable; @@ -226,8 +226,8 @@ private void Stop() { // This method gets called only on the ProcessEvents thread, or when that thread fails to start. // It closes the inotify handle. - Debug.Assert(!_isClosingHandle); - _isClosingHandle = true; + Debug.Assert(!_isProcessThreadStopping); + _isProcessThreadStopping = true; // Sync with AddOrUpdateWatchedDirectory and RemoveUnusedINotifyWatches. _addLock.EnterWriteLock(); @@ -252,7 +252,7 @@ private void Stop() // This ensures the WatchedDirectory matches with the most recent INotifyAddWatch directory. lock (watcher) { - if (_isClosingHandle || watcher.IsStopped) + if (_isProcessThreadStopping || watcher.IsStopped) { return null; } @@ -379,7 +379,7 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored _addLock.EnterWriteLock(); try { - if (_isClosingHandle) + if (_isProcessThreadStopping) { return; } @@ -1108,7 +1108,7 @@ private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) internal void Restart() { - Debug.Assert(_inotify._isClosingHandle); + Debug.Assert(_inotify._isProcessThreadStopping); lock (this) { From bf5c14d1c56db6d71e76c3b94c150321e595e489 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Thu, 3 Jul 2025 14:52:19 +0200 Subject: [PATCH 11/26] DequeueEvents: stop emitting events when we see IsStopped. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 7bfed4362d9aad..a5fbf12642882a 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -554,7 +554,6 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re foreach (var watcher in _watchers) { watcher.QueueError(CreateBufferOverflowException(watcher.BasePath)); - watcher.Restart(); } } return false; @@ -1059,19 +1058,26 @@ private async Task DequeueEvents() { await foreach (WatcherEvent evnt in _eventQueue.Reader.ReadAllAsync().ConfigureAwait(false)) { + if (IsStopped) + { + break; + } EmitEvent(evnt, pathBuffer); } } catch (Exception ex) { - Stop(); - - try + if (!IsStopped) { - Fsw?.OnError(new ErrorEventArgs(ex)); + Stop(); + + try + { + Fsw?.OnError(new ErrorEventArgs(ex)); + } + catch + { } } - catch - { } } } @@ -1087,6 +1093,20 @@ private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) { case WatcherEvent.ErrorType: fsw.OnError(new ErrorEventArgs(evnt.Exception!)); + + // On InternalBufferOverflowException, the inotify is stopped. + // If the Watcher wasn't stopped, Restart it against a new inotify instance. + if (evnt.Exception is InternalBufferOverflowException) + { + lock (this) + { + if (!IsStopped) + { + fsw.Restart(); + } + } + } + break; case WatcherChangeTypes.Created: case WatcherChangeTypes.Deleted: @@ -1106,23 +1126,6 @@ private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) } } - internal void Restart() - { - Debug.Assert(_inotify._isProcessThreadStopping); - - lock (this) - { - if (IsStopped) - { - return; - } - - // This will call Stop. - // Because our INotify instance is stopped, the Fsw will restart against a new INotify instance. - Fsw?.Restart(); - } - } - internal void Stop() { WatchedDirectory? root; From a9ac9b3ccad5e9b69e0c27d471b12afe43024a1f Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Mon, 7 Jul 2025 09:21:52 +0200 Subject: [PATCH 12/26] Start the Watcher after setting enabled. Use static s_watchersLock directly. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 110 +++++++++--------- 1 file changed, 52 insertions(+), 58 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index a5fbf12642882a..b6a9fe4a583d6d 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -62,8 +62,10 @@ private void StartRaisingEvents() return; } - _watcher = INotify.StartWatcher(this); + _watcher = new INotify.Watcher(this); _enabled = true; + + _watcher.Start(); } /// Cancels the currently running watch operation if there is one. @@ -106,31 +108,10 @@ private void FinalizeDispose() private sealed class INotify { - // Guards the watchers of the inotify instance. - public static readonly object s_watchersLock = new(); - + // Guards the watchers of the inotify instance and starting the inotify thread. + private static readonly object s_watchersLock = new(); private static INotify? s_currentInotify; - public static Watcher? StartWatcher(FileSystemWatcher fsw) - { - Watcher watcher; - lock (s_watchersLock) - { - // If there is no running instance, start one. - if (s_currentInotify is null || s_currentInotify.IsStopped) - { - INotify inotify = new(s_watchersLock); - inotify.Start(); - s_currentInotify = inotify; - } - - watcher = s_currentInotify.CreateWatcherCore(fsw); - } - - watcher.Start(); - return watcher; - } - /// /// The size of the native struct inotify_event. 4 32-bit integer values, the last of which is a length /// that indicates how many bytes follow to form the string name. @@ -139,7 +120,6 @@ private sealed class INotify public bool IsStopped => _isProcessThreadStopping || _allWatchersStopped; - private readonly object _watchersLock; private readonly List _watchers = new(); private readonly byte[] _buffer = new byte[16384]; private readonly SafeFileHandle _inotifyHandle; @@ -152,10 +132,8 @@ private sealed class INotify private int _bufferPos; private WatchedDirectory[] _dirBuffer = new WatchedDirectory[4]; - public INotify(object watcherLock) + public INotify() { - _watchersLock = watcherLock; - _inotifyHandle = CreateINotifyHandle(); static SafeFileHandle CreateINotifyHandle() @@ -185,24 +163,15 @@ static SafeFileHandle CreateINotifyHandle() } } - public Watcher CreateWatcherCore(FileSystemWatcher fsw) + private void AddWatcher(Watcher watcher) { - Debug.Assert(Monitor.IsEntered(_watchersLock)); - - var watcher = new Watcher(this, fsw); - - // We only add to the watchers if this is effectively watching something. - if (watcher.CreateRootWatch()) - { - _watchers.Add(watcher); - } - - return watcher; + Debug.Assert(Monitor.IsEntered(s_watchersLock)); + _watchers.Add(watcher); } public void Start() { - Debug.Assert(Monitor.IsEntered(_watchersLock)); + Debug.Assert(Monitor.IsEntered(s_watchersLock)); try { @@ -485,7 +454,7 @@ private void ProcessEvents() { try { - lock (_watchersLock) + lock (s_watchersLock) { // We've started an INotify, but no root watch was created for the FileSystemWatcher that started it. if (_watchers.Count == 0) @@ -510,7 +479,7 @@ private void ProcessEvents() } catch (Exception ex) { - lock (_watchersLock) + lock (s_watchersLock) { Stop(); @@ -547,7 +516,7 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re // An overflow event means we missed events. if ((mask & Interop.Sys.NotifyEvents.IN_Q_OVERFLOW) != 0) { - lock (_watchersLock) + lock (s_watchersLock) { Stop(); @@ -1002,7 +971,7 @@ public sealed class Watcher /// read to wake up in the event that the user neglects to stop raising events. /// private readonly WeakReference _weakFsw; - private readonly INotify _inotify; + private INotify? _inotify; private readonly Channel _eventQueue; public string BasePath { get; } public NotifyFilters NotifyFilters { get; } @@ -1013,9 +982,8 @@ public sealed class Watcher public bool IsStopped { get; set; } public WatchedDirectory? RootDirectory { get; set; } - public Watcher(INotify inotify, FileSystemWatcher fsw) + public Watcher(FileSystemWatcher fsw) { - _inotify = inotify; _weakFsw = new WeakReference(fsw); BasePath = System.IO.Path.TrimEndingDirectorySeparator(System.IO.Path.GetFullPath(fsw.Path)); IncludeSubdirectories = fsw.IncludeSubdirectories; @@ -1024,22 +992,30 @@ public Watcher(INotify inotify, FileSystemWatcher fsw) _eventQueue = Channel.CreateUnbounded(new UnboundedChannelOptions() { AllowSynchronousContinuations = false, SingleReader = true }); } - internal bool CreateRootWatch() + public void Start() { - RootDirectory = _inotify.AddOrUpdateWatchedDirectory(this, parent: null, BasePath, WatchFilters, followLinks: true, ignoreMissing: false); - - bool hasRootWatch = RootDirectory is not null; + Debug.Assert(_inotify is null); - if (hasRootWatch) + INotify? inotify; + lock (s_watchersLock) { - _ = DequeueEvents(); - } + inotify = s_currentInotify; + // If there is no running instance, start one. + if (inotify is null || inotify.IsStopped) + { + inotify = new(); + inotify.Start(); + s_currentInotify = inotify; + } - return hasRootWatch; - } + _inotify = inotify; + + if (CreateRootWatch()) + { + _inotify.AddWatcher(this); + } + } - internal void Start() - { if (RootDirectory is { } dir && IncludeSubdirectories) { if (IncludeSubdirectories) @@ -1051,6 +1027,22 @@ internal void Start() EmitEvents = true; } + internal bool CreateRootWatch() + { + Debug.Assert(_inotify is not null); + + RootDirectory = _inotify.AddOrUpdateWatchedDirectory(this, parent: null, BasePath, WatchFilters, followLinks: true, ignoreMissing: false); + + bool hasRootWatch = RootDirectory is not null; + + if (hasRootWatch) + { + _ = DequeueEvents(); + } + + return hasRootWatch; + } + private async Task DequeueEvents() { char[] pathBuffer = new char[PATH_MAX]; @@ -1145,12 +1137,14 @@ internal void Stop() if (root is not null) { + Debug.Assert(_inotify is not null); _inotify.RemoveWatchedDirectory(root); } } public bool WatchChildDirectories(WatchedDirectory parent, string path, bool includeBasePath = true) { + Debug.Assert(_inotify is not null); if (IsStopped) { return false; From 679643d7ceb60f729002a74cf6f7d7f397d2a818 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Mon, 7 Jul 2025 09:35:12 +0200 Subject: [PATCH 13/26] Remove unneeded lock. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index b6a9fe4a583d6d..cdca5aa1cf819b 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -1090,12 +1090,9 @@ private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) // If the Watcher wasn't stopped, Restart it against a new inotify instance. if (evnt.Exception is InternalBufferOverflowException) { - lock (this) + if (!IsStopped) { - if (!IsStopped) - { - fsw.Restart(); - } + fsw.Restart(); } } From f7be23dcce4ea5f541d7d0d33dd814f7a106ad9f Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Tue, 8 Jul 2025 08:19:36 +0200 Subject: [PATCH 14/26] Always start dequeuing events. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index cdca5aa1cf819b..a440d5daeec11f 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -1035,10 +1035,7 @@ internal bool CreateRootWatch() bool hasRootWatch = RootDirectory is not null; - if (hasRootWatch) - { - _ = DequeueEvents(); - } + _ = DequeueEvents(); return hasRootWatch; } From 84507109055dbed69995d3dc0c63f4e8589c1c38 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Tue, 8 Jul 2025 11:43:08 +0200 Subject: [PATCH 15/26] Don't add child watches once the root was removed. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index a440d5daeec11f..b9a95aa75970d6 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -221,7 +221,9 @@ private void Stop() // This ensures the WatchedDirectory matches with the most recent INotifyAddWatch directory. lock (watcher) { - if (_isProcessThreadStopping || watcher.IsStopped) + if (_isProcessThreadStopping // inotify thread stopping + || watcher.IsStopped // user stopped raising events + || (parent is not null && watcher.RootDirectory is null)) // process events removed the root { return null; } From 78f45cb66dc43e1304c44f29548f28308caae2fb Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Tue, 8 Jul 2025 13:31:31 +0200 Subject: [PATCH 16/26] Non-functional code changes (renames, visibility modifiers, moving code). --- .../src/System/IO/FileSystemWatcher.Linux.cs | 150 +++++++++--------- 1 file changed, 74 insertions(+), 76 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index b9a95aa75970d6..22528d48da5e7e 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -108,26 +108,22 @@ private void FinalizeDispose() private sealed class INotify { - // Guards the watchers of the inotify instance and starting the inotify thread. - private static readonly object s_watchersLock = new(); - private static INotify? s_currentInotify; - /// /// The size of the native struct inotify_event. 4 32-bit integer values, the last of which is a length /// that indicates how many bytes follow to form the string name. /// - private const int c_INotifyEventSize = 16; - - public bool IsStopped => _isProcessThreadStopping || _allWatchersStopped; + private const int INotifyEventSize = 16; + // Guards the watchers of the inotify instance and starting the inotify thread. + private static readonly object s_watchersLock = new(); + private static INotify? s_currentInotify; private readonly List _watchers = new(); private readonly byte[] _buffer = new byte[16384]; private readonly SafeFileHandle _inotifyHandle; private readonly ConcurrentDictionary _wdToWatch = new ConcurrentDictionary(); private readonly ReaderWriterLockSlim _addLock = new(LockRecursionPolicy.NoRecursion); - private bool _isProcessThreadStopping; + private bool _isThreadStopping; private bool _allWatchersStopped; - private int _bufferAvailable; private int _bufferPos; private WatchedDirectory[] _dirBuffer = new WatchedDirectory[4]; @@ -163,13 +159,15 @@ static SafeFileHandle CreateINotifyHandle() } } + private bool IsStopped => _isThreadStopping || _allWatchersStopped; + private void AddWatcher(Watcher watcher) { Debug.Assert(Monitor.IsEntered(s_watchersLock)); _watchers.Add(watcher); } - public void Start() + private void StartThread() { Debug.Assert(Monitor.IsEntered(s_watchersLock)); @@ -185,18 +183,18 @@ public void Start() } catch { - Stop(); + StopINotify(); throw; } } - private void Stop() + private void StopINotify() { // This method gets called only on the ProcessEvents thread, or when that thread fails to start. // It closes the inotify handle. - Debug.Assert(!_isProcessThreadStopping); - _isProcessThreadStopping = true; + Debug.Assert(!_isThreadStopping); + _isThreadStopping = true; // Sync with AddOrUpdateWatchedDirectory and RemoveUnusedINotifyWatches. _addLock.EnterWriteLock(); @@ -206,7 +204,7 @@ private void Stop() _inotifyHandle.Dispose(); } - public WatchedDirectory? AddOrUpdateWatchedDirectory(Watcher watcher, WatchedDirectory? parent, string directoryPath, Interop.Sys.NotifyEvents watchFilters, bool followLinks = false, bool ignoreMissing = true) + private WatchedDirectory? AddOrUpdateWatchedDirectory(Watcher watcher, WatchedDirectory? parent, string directoryPath, Interop.Sys.NotifyEvents watchFilters, bool followLinks = false, bool ignoreMissing = true) { WatchedDirectory? inotifyWatchesToRemove = null; WatchedDirectory dir; @@ -221,7 +219,7 @@ private void Stop() // This ensures the WatchedDirectory matches with the most recent INotifyAddWatch directory. lock (watcher) { - if (_isProcessThreadStopping // inotify thread stopping + if (_isThreadStopping // inotify thread stopping || watcher.IsStopped // user stopped raising events || (parent is not null && watcher.RootDirectory is null)) // process events removed the root { @@ -330,7 +328,7 @@ private void Stop() return dir; } - public void RemoveWatchedDirectory(WatchedDirectory dir, int ignoredFd = -1) + private void RemoveWatchedDirectory(WatchedDirectory dir, int ignoredFd = -1) { bool removeINotifyWatches = false; @@ -350,7 +348,7 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored _addLock.EnterWriteLock(); try { - if (_isProcessThreadStopping) + if (_isThreadStopping) { return; } @@ -461,7 +459,7 @@ private void ProcessEvents() // We've started an INotify, but no root watch was created for the FileSystemWatcher that started it. if (_watchers.Count == 0) { - Stop(); + StopINotify(); return; } } @@ -483,7 +481,7 @@ private void ProcessEvents() { lock (s_watchersLock) { - Stop(); + StopINotify(); foreach (var watcher in _watchers) { @@ -520,7 +518,7 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re { lock (s_watchersLock) { - Stop(); + StopINotify(); foreach (var watcher in _watchers) { @@ -693,12 +691,30 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re // If we didn't find back the WatchedDirectory, then RemoveWatchedDirectory was called already and it has updated _allWatchersStopped. if (_allWatchersStopped) { - Stop(); + StopINotify(); return false; } return true; + void RemoveWatchedDirectoryChild(WatchedDirectory dir, string movedFromName) + { + Watcher watcher = dir.Watcher; + WatchedDirectory? child = null; + lock (watcher) + { + int idx = dir.FindChild(movedFromName); + if (idx != -1) + { + child = dir.Children![idx]; + } + } + if (child is not null) + { + RemoveWatchedDirectory(child); + } + } + static ReadOnlySpan GetWatchedDirectories(Watch watch, ref WatchedDirectory[] buffer, int offset) { lock (watch) @@ -727,24 +743,6 @@ static ReadOnlySpan GetWatchedDirectories(Watch watch, ref Wat return null; } - void RemoveWatchedDirectoryChild(WatchedDirectory dir, string movedFromName) - { - Watcher watcher = dir.Watcher; - WatchedDirectory? child = null; - lock (watcher) - { - int idx = dir.FindChild(movedFromName); - if (idx != -1) - { - child = dir.Children![idx]; - } - } - if (child is not null) - { - RemoveWatchedDirectory(child); - } - } - static bool IsIgnoredEvent(Watcher watcher, Interop.Sys.NotifyEvents mask, bool isDir) { return (watcher.WatchFilters & mask) == 0 || @@ -850,7 +848,7 @@ private bool TryReadEvent(out NotifyEvent notifyEvent) notifyEvent = default(NotifyEvent); return false; } - Debug.Assert(_bufferAvailable >= c_INotifyEventSize); + Debug.Assert(_bufferAvailable >= INotifyEventSize); _bufferPos = 0; } @@ -862,14 +860,14 @@ private bool TryReadEvent(out NotifyEvent notifyEvent) // uint32_t len; // char name[]; // length determined by len; at least 1 for required null termination // }; - Debug.Assert(_bufferPos + c_INotifyEventSize <= _bufferAvailable); + Debug.Assert(_bufferPos + INotifyEventSize <= _bufferAvailable); NotifyEvent readEvent; readEvent.wd = BitConverter.ToInt32(_buffer, _bufferPos); readEvent.mask = BitConverter.ToUInt32(_buffer, _bufferPos + 4); // +4 to get past wd readEvent.cookie = BitConverter.ToUInt32(_buffer, _bufferPos + 8); // +8 to get past wd, mask int nameLength = (int)BitConverter.ToUInt32(_buffer, _bufferPos + 12); // +12 to get past wd, mask, cookie - readEvent.name = ReadName(_bufferPos + c_INotifyEventSize, nameLength); // +16 to get past wd, mask, cookie, len - _bufferPos += c_INotifyEventSize + nameLength; + readEvent.name = ReadName(_bufferPos + INotifyEventSize, nameLength); // +16 to get past wd, mask, cookie, len + _bufferPos += INotifyEventSize + nameLength; notifyEvent = readEvent; return true; @@ -973,14 +971,14 @@ public sealed class Watcher /// read to wake up in the event that the user neglects to stop raising events. /// private readonly WeakReference _weakFsw; - private INotify? _inotify; private readonly Channel _eventQueue; + private INotify? _inotify; + private bool _emitEvents; + public string BasePath { get; } public NotifyFilters NotifyFilters { get; } public Interop.Sys.NotifyEvents WatchFilters { get; } public bool IncludeSubdirectories { get; } - - public bool EmitEvents { get; set; } public bool IsStopped { get; set; } public WatchedDirectory? RootDirectory { get; set; } @@ -1006,7 +1004,7 @@ public void Start() if (inotify is null || inotify.IsStopped) { inotify = new(); - inotify.Start(); + inotify.StartThread(); s_currentInotify = inotify; } @@ -1026,10 +1024,34 @@ public void Start() } } - EmitEvents = true; + _emitEvents = true; } - internal bool CreateRootWatch() + public void Stop() + { + WatchedDirectory? root; + lock (this) + { + if (IsStopped) + { + return; + } + IsStopped = true; + _emitEvents = false; + + root = RootDirectory; + } + + _eventQueue.Writer.Complete(); + + if (root is not null) + { + Debug.Assert(_inotify is not null); + _inotify.RemoveWatchedDirectory(root); + } + } + + private bool CreateRootWatch() { Debug.Assert(_inotify is not null); @@ -1114,31 +1136,7 @@ private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) } } - internal void Stop() - { - WatchedDirectory? root; - lock (this) - { - if (IsStopped) - { - return; - } - IsStopped = true; - EmitEvents = false; - - root = RootDirectory; - } - - _eventQueue.Writer.Complete(); - - if (root is not null) - { - Debug.Assert(_inotify is not null); - _inotify.RemoveWatchedDirectory(root); - } - } - - public bool WatchChildDirectories(WatchedDirectory parent, string path, bool includeBasePath = true) + internal bool WatchChildDirectories(WatchedDirectory parent, string path, bool includeBasePath = true) { Debug.Assert(_inotify is not null); if (IsStopped) @@ -1185,7 +1183,7 @@ public bool WatchChildDirectories(WatchedDirectory parent, string path, bool inc internal void QueueEvent(WatcherEvent ev) { Debug.Assert(ev.Type != WatcherEvent.ErrorType); - if (!EmitEvents) + if (!_emitEvents) { return; } From 042b4a196f0f1844986b6d8d8d9f79f46fda0ce7 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Tue, 8 Jul 2025 19:59:00 +0200 Subject: [PATCH 17/26] Assign RootDirectory in AddOrUpdateWatchedDirectory. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 22528d48da5e7e..cc244bc49b8d4a 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -206,6 +206,8 @@ private void StopINotify() private WatchedDirectory? AddOrUpdateWatchedDirectory(Watcher watcher, WatchedDirectory? parent, string directoryPath, Interop.Sys.NotifyEvents watchFilters, bool followLinks = false, bool ignoreMissing = true) { + Debug.Assert(!Monitor.IsEntered(watcher)); // We musn't hold the watcher lock prior to taking the _addLock. + WatchedDirectory? inotifyWatchesToRemove = null; WatchedDirectory dir; @@ -280,6 +282,7 @@ private void StopINotify() { Debug.Assert(watcher.RootDirectory is null); dir = new WatchedDirectory(watch, watcher, "", parent); + watcher.RootDirectory = dir; } else { @@ -342,6 +345,8 @@ private void RemoveWatchedDirectory(WatchedDirectory dir, int ignoredFd = -1) private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignoredFd = -1) { + Debug.Assert(!Monitor.IsEntered(removedDir.Watcher)); // We musn't hold the watcher lock prior to taking the _addLock. + // _addLock stops handles from being added while we'll removing watches. // This is needed to prevent removing watch descriptors between INotifyAddWatch and adding them to the Watch.Watchers. // _addLock is also used to synchronizes with Stop. @@ -362,12 +367,12 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored } if (removedDir.Children is { } children) + { + foreach (var child in children) { - foreach (var child in children) - { - RemoveINotifyWatchWhenNoMoreWatchers(child.Watch, ignoredFd); - } + RemoveINotifyWatchWhenNoMoreWatchers(child.Watch, ignoredFd); } + } } finally { @@ -997,6 +1002,7 @@ public void Start() Debug.Assert(_inotify is null); INotify? inotify; + WatchedDirectory? rootDirectory; lock (s_watchersLock) { inotify = s_currentInotify; @@ -1010,21 +1016,27 @@ public void Start() _inotify = inotify; - if (CreateRootWatch()) + rootDirectory = CreateRootWatch(); + if (rootDirectory is not null) { _inotify.AddWatcher(this); } } - if (RootDirectory is { } dir && IncludeSubdirectories) + _ = DequeueEvents(); + + if (rootDirectory is not null && IncludeSubdirectories) { if (IncludeSubdirectories) { - WatchChildDirectories(dir, BasePath, includeBasePath: false); + WatchChildDirectories(rootDirectory, BasePath, includeBasePath: false); } } _emitEvents = true; + + WatchedDirectory? CreateRootWatch() + => _inotify.AddOrUpdateWatchedDirectory(this, parent: null, BasePath, WatchFilters, followLinks: true, ignoreMissing: false); } public void Stop() @@ -1051,19 +1063,6 @@ public void Stop() } } - private bool CreateRootWatch() - { - Debug.Assert(_inotify is not null); - - RootDirectory = _inotify.AddOrUpdateWatchedDirectory(this, parent: null, BasePath, WatchFilters, followLinks: true, ignoreMissing: false); - - bool hasRootWatch = RootDirectory is not null; - - _ = DequeueEvents(); - - return hasRootWatch; - } - private async Task DequeueEvents() { char[] pathBuffer = new char[PATH_MAX]; From e3dd4353ffb15091892fb640ba75dd50febb5dcc Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Wed, 9 Jul 2025 09:14:13 +0200 Subject: [PATCH 18/26] Add additional Debug.Assert Monitor.IsEntered for checking locks are taken. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index cc244bc49b8d4a..96da225ec86d7c 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -366,11 +366,14 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored return; } - if (removedDir.Children is { } children) + lock (removedDir.Watcher) { - foreach (var child in children) + if (removedDir.Children is { } children) { - RemoveINotifyWatchWhenNoMoreWatchers(child.Watch, ignoredFd); + foreach (var child in children) + { + RemoveINotifyWatchWhenNoMoreWatchers(child.Watch, ignoredFd); + } } } } @@ -979,13 +982,27 @@ public sealed class Watcher private readonly Channel _eventQueue; private INotify? _inotify; private bool _emitEvents; + private WatchedDirectory? _rootDirectory; public string BasePath { get; } public NotifyFilters NotifyFilters { get; } public Interop.Sys.NotifyEvents WatchFilters { get; } public bool IncludeSubdirectories { get; } public bool IsStopped { get; set; } - public WatchedDirectory? RootDirectory { get; set; } + + public WatchedDirectory? RootDirectory + { + get + { + Debug.Assert(Monitor.IsEntered(this)); + return _rootDirectory; + } + set + { + Debug.Assert(Monitor.IsEntered(this)); + _rootDirectory = value; + } + } public Watcher(FileSystemWatcher fsw) { @@ -1274,6 +1291,8 @@ private static Interop.Sys.NotifyEvents TranslateFilters(NotifyFilters filters) internal sealed class WatchedDirectory { + private List? _children; + public Watch Watch { get; } public Watcher Watcher { get; } public string Name { get; } @@ -1288,7 +1307,19 @@ public WatchedDirectory(Watch watch, Watcher watcher, string name, WatchedDirect Parent = parent; } - public List? Children; + public List? Children + { + get + { + Debug.Assert(Monitor.IsEntered(Watcher)); + return _children; + } + set + { + Debug.Assert(Monitor.IsEntered(Watcher)); + _children = value; + } + } public List InitializedChildren => Children ??= new List(); public int FindChild(string name) @@ -1350,8 +1381,17 @@ void Append(Span pathBuffer, ReadOnlySpan path) internal sealed class Watch { + private List _watchers = new(); + public int WatchDescriptor { get; } - public List Watchers { get; } = new(); + public List Watchers + { + get + { + Debug.Assert(Monitor.IsEntered(this)); + return _watchers; + } + } public Watch(int watchDescriptor) { From 8e65598242e4487dc6302dc77e85ac4b6a0217ef Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Thu, 10 Jul 2025 09:09:42 +0200 Subject: [PATCH 19/26] Add some notes about the buffer/queue sizes. --- .../src/System.IO.FileSystem.Watcher.csproj | 4 ++-- .../src/System/IO/FileSystemWatcher.Linux.cs | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj b/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj index ef61b5db2ece20..71bcacb55d9236 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj @@ -133,8 +133,8 @@ - - + + diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 96da225ec86d7c..2104239dcdd1bb 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -114,11 +114,17 @@ private sealed class INotify /// private const int INotifyEventSize = 16; + // The name buffer in struct inotify_event is 0-256 bytes making the total inotify_event size 16-272 bytes. + // The below buffer fits at 60+ events of the largest events. + // For a typical file name size of <32 bytes, we can receive 300+ events in a single read. + // This buffer size is assumed to be be plenty because the read loop dispatches the work for user event handling to the ThreadPool and then performs a new read. + private const int BufferSize = 16384; + // Guards the watchers of the inotify instance and starting the inotify thread. private static readonly object s_watchersLock = new(); private static INotify? s_currentInotify; private readonly List _watchers = new(); - private readonly byte[] _buffer = new byte[16384]; + private readonly byte[] _buffer = new byte[BufferSize]; private readonly SafeFileHandle _inotifyHandle; private readonly ConcurrentDictionary _wdToWatch = new ConcurrentDictionary(); private readonly ReaderWriterLockSlim _addLock = new(LockRecursionPolicy.NoRecursion); @@ -1011,6 +1017,9 @@ public Watcher(FileSystemWatcher fsw) IncludeSubdirectories = fsw.IncludeSubdirectories; NotifyFilters = fsw.NotifyFilter; WatchFilters = TranslateFilters(NotifyFilters); + + // This channel is unbounded which means that if the FileSystemWatcher event handlers can't keep up, the queue size will increase and consume memory. + // We could implement a bound that is based on the FileSystemWatcher.InternalBufferSize property. _eventQueue = Channel.CreateUnbounded(new UnboundedChannelOptions() { AllowSynchronousContinuations = false, SingleReader = true }); } From db7cd335d9622c751a1af396b0141c2bd61f4986 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Thu, 4 Dec 2025 03:56:55 +0100 Subject: [PATCH 20/26] Non-functional cleanup. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 2104239dcdd1bb..b249ae0805ce06 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -117,7 +117,7 @@ private sealed class INotify // The name buffer in struct inotify_event is 0-256 bytes making the total inotify_event size 16-272 bytes. // The below buffer fits at 60+ events of the largest events. // For a typical file name size of <32 bytes, we can receive 300+ events in a single read. - // This buffer size is assumed to be be plenty because the read loop dispatches the work for user event handling to the ThreadPool and then performs a new read. + // This buffer size is assumed to be plenty because the read loop dispatches the work for user event handling to the ThreadPool and then performs a new read. private const int BufferSize = 16384; // Guards the watchers of the inotify instance and starting the inotify thread. @@ -212,7 +212,7 @@ private void StopINotify() private WatchedDirectory? AddOrUpdateWatchedDirectory(Watcher watcher, WatchedDirectory? parent, string directoryPath, Interop.Sys.NotifyEvents watchFilters, bool followLinks = false, bool ignoreMissing = true) { - Debug.Assert(!Monitor.IsEntered(watcher)); // We musn't hold the watcher lock prior to taking the _addLock. + Debug.Assert(!Monitor.IsEntered(watcher)); // We mustn't hold the watcher lock prior to taking the _addLock. WatchedDirectory? inotifyWatchesToRemove = null; WatchedDirectory dir; @@ -223,7 +223,7 @@ private void StopINotify() try { // Serialize adding watches to the same watcher. - // Concurrently adding watches may happen during the initial reursive iteration of the directory. + // Concurrently adding watches may happen during the initial recursive iteration of the directory. // This ensures the WatchedDirectory matches with the most recent INotifyAddWatch directory. lock (watcher) { @@ -237,8 +237,7 @@ private void StopINotify() Interop.Sys.NotifyEvents mask = watchFilters | Interop.Sys.NotifyEvents.IN_ONLYDIR | // we only allow watches on directories Interop.Sys.NotifyEvents.IN_EXCL_UNLINK | // we want to stop monitoring unlinked files - (followLinks ? 0 : Interop.Sys.NotifyEvents.IN_DONT_FOLLOW | - Interop.Sys.NotifyEvents.IN_MASK_ADD); + (followLinks ? 0 : Interop.Sys.NotifyEvents.IN_DONT_FOLLOW); // To support multiple FileSystemWatchers on the same inotify instance, we need to use IN_MASK_ADD // so we don't remove events another watcher is interested in. @@ -548,7 +547,7 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re ReadOnlySpan movedFromDirs = _dirBuffer.AsSpan(0, movedFromWatchCount); // Look up the Watch in _wdToWatch. - // We take a writer lock to synchronize with AddOrUpdateWatchedDirectory and make sure newly added watch descriptors can be found in _wdToWatch. + // Synchronize with AddOrUpdateWatchedDirectory to make sure newly added watch descriptors can be found in _wdToWatch. _addLock.EnterWriteLock(); _addLock.ExitWriteLock(); _wdToWatch.TryGetValue(nextEvent.wd, out Watch? watch); @@ -1053,10 +1052,7 @@ public void Start() if (rootDirectory is not null && IncludeSubdirectories) { - if (IncludeSubdirectories) - { - WatchChildDirectories(rootDirectory, BasePath, includeBasePath: false); - } + WatchChildDirectories(rootDirectory, BasePath, includeBasePath: false); } _emitEvents = true; From 219fbe5971c5ca750731aa14b9ef94e0e13ea015 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Thu, 11 Dec 2025 09:02:09 +0100 Subject: [PATCH 21/26] Apply suggestions from Copilot. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/System/IO/FileSystemWatcher.Linux.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index b249ae0805ce06..4d7c130815f113 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -281,7 +281,7 @@ private void StopINotify() return null; } - Watch watch = _wdToWatch.AddOrUpdate(wd, (int wd) => new Watch(wd), (int wd, Watch current) => current); + Watch watch = _wdToWatch.AddOrUpdate(wd, (int watchDescriptor) => new Watch(watchDescriptor), (int watchDescriptor, Watch current) => current); if (parent is null) { @@ -350,11 +350,11 @@ private void RemoveWatchedDirectory(WatchedDirectory dir, int ignoredFd = -1) private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignoredFd = -1) { - Debug.Assert(!Monitor.IsEntered(removedDir.Watcher)); // We musn't hold the watcher lock prior to taking the _addLock. + Debug.Assert(!Monitor.IsEntered(removedDir.Watcher)); // We mustn't hold the watcher lock prior to taking the _addLock. - // _addLock stops handles from being added while we'll removing watches. + // _addLock stops handles from being added while we're removing watches. // This is needed to prevent removing watch descriptors between INotifyAddWatch and adding them to the Watch.Watchers. - // _addLock is also used to synchronizes with Stop. + // _addLock is also used to synchronize with Stop. _addLock.EnterWriteLock(); try { From fcd605770c706bb97db158a96abe9ceb673de232 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Thu, 11 Dec 2025 09:10:53 +0100 Subject: [PATCH 22/26] Make more clear how StopINotify syncs with AddOrUpdateWatchedDirectory and RemoveUnusedINotifyWatches. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 4d7c130815f113..2aeb8dac51040d 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -200,10 +200,10 @@ private void StopINotify() // This method gets called only on the ProcessEvents thread, or when that thread fails to start. // It closes the inotify handle. Debug.Assert(!_isThreadStopping); - _isThreadStopping = true; - // Sync with AddOrUpdateWatchedDirectory and RemoveUnusedINotifyWatches. + // Sync with AddOrUpdateWatchedDirectory and RemoveUnusedINotifyWatches to stop using the inotify handle. _addLock.EnterWriteLock(); + _isThreadStopping = true; _addLock.ExitWriteLock(); // Close the handle. From de35402a30fd23387071b44ac5b028c2f6bbef3c Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Wed, 21 Jan 2026 09:29:10 +0100 Subject: [PATCH 23/26] PR feedback. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 2aeb8dac51040d..741fbabcc95689 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -210,7 +210,7 @@ private void StopINotify() _inotifyHandle.Dispose(); } - private WatchedDirectory? AddOrUpdateWatchedDirectory(Watcher watcher, WatchedDirectory? parent, string directoryPath, Interop.Sys.NotifyEvents watchFilters, bool followLinks = false, bool ignoreMissing = true) + private WatchedDirectory? AddOrUpdateWatchedDirectory(Watcher watcher, WatchedDirectory? parent, string directoryPath, Interop.Sys.NotifyEvents watchFilters, bool ignoreMissing = true) { Debug.Assert(!Monitor.IsEntered(watcher)); // We mustn't hold the watcher lock prior to taking the _addLock. @@ -237,16 +237,17 @@ private void StopINotify() Interop.Sys.NotifyEvents mask = watchFilters | Interop.Sys.NotifyEvents.IN_ONLYDIR | // we only allow watches on directories Interop.Sys.NotifyEvents.IN_EXCL_UNLINK | // we want to stop monitoring unlinked files - (followLinks ? 0 : Interop.Sys.NotifyEvents.IN_DONT_FOLLOW); + (parent == null ? 0 : Interop.Sys.NotifyEvents.IN_DONT_FOLLOW); // Follow links only for the root path, not the subdirs. // To support multiple FileSystemWatchers on the same inotify instance, we need to use IN_MASK_ADD // so we don't remove events another watcher is interested in. // The downside is that we won't unsubscribe from events that are unique to a watcher when it stops. mask |= Interop.Sys.NotifyEvents.IN_MASK_ADD; + // Track when directories are added/removed. if (watcher.IncludeSubdirectories) { - mask |= Interop.Sys.NotifyEvents.IN_CREATE | Interop.Sys.NotifyEvents.IN_MOVED_TO | Interop.Sys.NotifyEvents.IN_MOVED_FROM; + mask |= Interop.Sys.NotifyEvents.IN_MOVED_TO | Interop.Sys.NotifyEvents.IN_MOVED_FROM; } int wd = Interop.Sys.INotifyAddWatch(_inotifyHandle, directoryPath, (uint)mask); @@ -303,7 +304,7 @@ private void StopINotify() return dir; } - // The current watch is watching a different directory, use the new watch instead. + // The current watch is watching a different directory which was moved/deleted, use the new watch instead. bool removeINotifyWatches = false; RemoveWatchedDirectoryFromParentAndWatches(dir, ref removeINotifyWatches); @@ -987,7 +988,6 @@ public sealed class Watcher private readonly Channel _eventQueue; private INotify? _inotify; private bool _emitEvents; - private WatchedDirectory? _rootDirectory; public string BasePath { get; } public NotifyFilters NotifyFilters { get; } @@ -1000,12 +1000,12 @@ public WatchedDirectory? RootDirectory get { Debug.Assert(Monitor.IsEntered(this)); - return _rootDirectory; + return field; } set { Debug.Assert(Monitor.IsEntered(this)); - _rootDirectory = value; + field = value; } } @@ -1058,7 +1058,7 @@ public void Start() _emitEvents = true; WatchedDirectory? CreateRootWatch() - => _inotify.AddOrUpdateWatchedDirectory(this, parent: null, BasePath, WatchFilters, followLinks: true, ignoreMissing: false); + => _inotify.AddOrUpdateWatchedDirectory(this, parent: null, BasePath, WatchFilters, ignoreMissing: false); } public void Stop() @@ -1198,7 +1198,7 @@ internal bool WatchChildDirectories(WatchedDirectory parent, string path, bool i return true; WatchedDirectory? AddOrUpdateWatch(WatchedDirectory parent, string path) - => _inotify.AddOrUpdateWatchedDirectory(this, parent, path, WatchFilters, followLinks: false, ignoreMissing: true); + => _inotify.AddOrUpdateWatchedDirectory(this, parent, path, WatchFilters, ignoreMissing: true); } internal void QueueEvent(WatcherEvent ev) From e265ce9063cf8402bb969261021eab6210e1c9eb Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Thu, 22 Jan 2026 11:38:39 +0100 Subject: [PATCH 24/26] Don't restart when InternalBufferSize is set. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 6 ++++++ .../src/System/IO/FileSystemWatcher.OSX.cs | 3 +++ .../src/System/IO/FileSystemWatcher.Win32.cs | 3 +++ .../src/System/IO/FileSystemWatcher.cs | 2 +- 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index 741fbabcc95689..e3939fd6593561 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -97,6 +97,11 @@ private void FinalizeDispose() private INotify.Watcher? _watcher; + private void RestartForInternalBufferSize() + { + // The implementation is not using InternalBufferSize. There's no need to restart. + } + /// Reads the value of a max user limit path from procfs. /// The path to read. /// The value read, or "0" if a failure occurred. @@ -1019,6 +1024,7 @@ public Watcher(FileSystemWatcher fsw) // This channel is unbounded which means that if the FileSystemWatcher event handlers can't keep up, the queue size will increase and consume memory. // We could implement a bound that is based on the FileSystemWatcher.InternalBufferSize property. + // If InternalBufferSize were used, RestartForInternalBufferSize can be removed/should be updated. _eventQueue = Channel.CreateUnbounded(new UnboundedChannelOptions() { AllowSynchronousContinuations = false, SingleReader = true }); } diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.OSX.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.OSX.cs index caef9c8a427d70..8853cad4f644bc 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.OSX.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.OSX.cs @@ -77,6 +77,9 @@ private void StopRaisingEvents() private CancellationTokenSource? _cancellation; + private void RestartForInternalBufferSize() + => Restart(); + private static FSEventStreamEventFlags TranslateFlags(NotifyFilters flagsToTranslate) { FSEventStreamEventFlags flags = 0; diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Win32.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Win32.cs index 78a5729d8b2459..d8f6da37263fd0 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Win32.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Win32.cs @@ -434,5 +434,8 @@ public void Dispose() DirectoryHandle?.Dispose(); } } + + private void RestartForInternalBufferSize() + => Restart(); } } diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.cs index 3c6f71da3f838f..25dd4cde225db3 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.cs @@ -218,7 +218,7 @@ public int InternalBufferSize _internalBufferSize = (uint)value; } - Restart(); + RestartForInternalBufferSize(); } } } From 48269459339d8db5bf13d1a342703c9a245c0f09 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Thu, 22 Jan 2026 18:36:08 +0100 Subject: [PATCH 25/26] Make RestartForInternalBufferSize static on Linux as required by CA1822. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index e3939fd6593561..c7eb579be7815a 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -97,7 +97,7 @@ private void FinalizeDispose() private INotify.Watcher? _watcher; - private void RestartForInternalBufferSize() + private static void RestartForInternalBufferSize() { // The implementation is not using InternalBufferSize. There's no need to restart. } From f3c9a4a59d0a0457d545deb83b64ce33e5df9462 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Fri, 23 Jan 2026 10:35:13 +0100 Subject: [PATCH 26/26] PR feedback. --- .../src/System/IO/FileSystemWatcher.Linux.cs | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs index c7eb579be7815a..07edfcf2e2a2d0 100644 --- a/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs +++ b/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Linux.cs @@ -127,7 +127,7 @@ private sealed class INotify // Guards the watchers of the inotify instance and starting the inotify thread. private static readonly object s_watchersLock = new(); - private static INotify? s_currentInotify; + private static INotify? s_currentINotify; private readonly List _watchers = new(); private readonly byte[] _buffer = new byte[BufferSize]; private readonly SafeFileHandle _inotifyHandle; @@ -233,7 +233,7 @@ private void StopINotify() lock (watcher) { if (_isThreadStopping // inotify thread stopping - || watcher.IsStopped // user stopped raising events + || watcher.IsWatcherStopped // user stopped raising events || (parent is not null && watcher.RootDirectory is null)) // process events removed the root { return null; @@ -381,7 +381,7 @@ private void RemoveUnusedINotifyWatches(WatchedDirectory removedDir, int ignored { if (removedDir.Children is { } children) { - foreach (var child in children) + foreach (WatchedDirectory child in children) { RemoveINotifyWatchWhenNoMoreWatchers(child.Watch, ignoredFd); } @@ -489,8 +489,7 @@ private void ProcessEvents() uint movedFromCookie = 0; bool movedFromIsDir = false; - NotifyEvent nextEvent; - while (TryReadEvent(out nextEvent)) + while (TryReadEvent(out NotifyEvent nextEvent)) { if (!ProcessEvent(nextEvent, ref movedFromWatchCount, ref movedFromName, ref movedFromCookie, ref movedFromIsDir)) break; @@ -636,8 +635,7 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re foreach (WatchedDirectory dir in dirs) { Watcher watcher = dir.Watcher; - - WatchedDirectory? matchingFrom = (mask & Interop.Sys.NotifyEvents.IN_MOVED_TO) != 0 ? FindMatchingWatchedDirectory(movedFromDirs, watcher) : null; + WatchedDirectory? matchingFromFound = null; // cache FindMatchingFrom result. if (isDir && watcher.IncludeSubdirectories) { @@ -645,7 +643,7 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re { // If this is a rename, move over the watches from the source. // We'll still call WatchChildDirectories in case the source was still being iterated for adding watches. - if (matchingFrom is not null) + if (FindMatchingFrom(movedFromDirs) is WatchedDirectory matchingFrom) { RenameWatchedDirectories(dir, nextEvent.name, matchingFrom, movedFromName); } @@ -693,7 +691,7 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re watcher.QueueEvent(WatcherEvent.Deleted(dir, nextEvent.name)); break; case Interop.Sys.NotifyEvents.IN_MOVED_TO: - if (matchingFrom is not null) + if (FindMatchingFrom(movedFromDirs) is WatchedDirectory matchingFrom) { watcher.QueueEvent(WatcherEvent.Renamed(dir, nextEvent.name, matchingFrom, movedFromName)); } @@ -703,6 +701,9 @@ private bool ProcessEvent(NotifyEvent nextEvent, ref int movedFromWatchCount, re } break; } + + WatchedDirectory? FindMatchingFrom(ReadOnlySpan dirs) + => matchingFromFound ??= (mask & Interop.Sys.NotifyEvents.IN_MOVED_TO) != 0 ? FindMatchingWatchedDirectory(dirs, watcher) : null; } // For each Watcher we'll receive an IN_IGNORED for its root watch. @@ -998,7 +999,7 @@ public sealed class Watcher public NotifyFilters NotifyFilters { get; } public Interop.Sys.NotifyEvents WatchFilters { get; } public bool IncludeSubdirectories { get; } - public bool IsStopped { get; set; } + public bool IsWatcherStopped { get; set; } public WatchedDirectory? RootDirectory { @@ -1036,13 +1037,13 @@ public void Start() WatchedDirectory? rootDirectory; lock (s_watchersLock) { - inotify = s_currentInotify; + inotify = s_currentINotify; // If there is no running instance, start one. if (inotify is null || inotify.IsStopped) { inotify = new(); inotify.StartThread(); - s_currentInotify = inotify; + s_currentINotify = inotify; } _inotify = inotify; @@ -1072,11 +1073,11 @@ public void Stop() WatchedDirectory? root; lock (this) { - if (IsStopped) + if (IsWatcherStopped) { return; } - IsStopped = true; + IsWatcherStopped = true; _emitEvents = false; root = RootDirectory; @@ -1096,18 +1097,18 @@ private async Task DequeueEvents() char[] pathBuffer = new char[PATH_MAX]; try { - await foreach (WatcherEvent evnt in _eventQueue.Reader.ReadAllAsync().ConfigureAwait(false)) + await foreach (WatcherEvent @event in _eventQueue.Reader.ReadAllAsync().ConfigureAwait(false)) { - if (IsStopped) + if (IsWatcherStopped) { break; } - EmitEvent(evnt, pathBuffer); + EmitEvent(@event, pathBuffer); } } catch (Exception ex) { - if (!IsStopped) + if (!IsWatcherStopped) { Stop(); @@ -1121,7 +1122,7 @@ private async Task DequeueEvents() } } - private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) + private void EmitEvent(WatcherEvent @event, char[] pathBuffer) { FileSystemWatcher? fsw = Fsw; if (fsw is null) @@ -1129,16 +1130,16 @@ private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) return; } - switch (evnt.Type) + switch (@event.Type) { case WatcherEvent.ErrorType: - fsw.OnError(new ErrorEventArgs(evnt.Exception!)); + fsw.OnError(new ErrorEventArgs(@event.Exception!)); // On InternalBufferOverflowException, the inotify is stopped. // If the Watcher wasn't stopped, Restart it against a new inotify instance. - if (evnt.Exception is InternalBufferOverflowException) + if (@event.Exception is InternalBufferOverflowException) { - if (!IsStopped) + if (!IsWatcherStopped) { fsw.Restart(); } @@ -1149,14 +1150,14 @@ private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) case WatcherChangeTypes.Deleted: case WatcherChangeTypes.Changed: { - ReadOnlySpan name = evnt.GetName(pathBuffer); - fsw.NotifyFileSystemEventArgs(evnt.Type, name); + ReadOnlySpan name = @event.GetName(pathBuffer); + fsw.NotifyFileSystemEventArgs(@event.Type, name); } break; case WatcherChangeTypes.Renamed: { - string name = evnt.GetName(pathBuffer).ToString(); - ReadOnlySpan oldName = evnt.GetOldName(pathBuffer); + string name = @event.GetName(pathBuffer).ToString(); + ReadOnlySpan oldName = @event.GetOldName(pathBuffer); fsw.NotifyRenameEventArgs(WatcherChangeTypes.Renamed, name, oldName); } break; @@ -1166,7 +1167,7 @@ private void EmitEvent(WatcherEvent evnt, char[] pathBuffer) internal bool WatchChildDirectories(WatchedDirectory parent, string path, bool includeBasePath = true) { Debug.Assert(_inotify is not null); - if (IsStopped) + if (IsWatcherStopped) { return false; } @@ -1184,7 +1185,7 @@ internal bool WatchChildDirectories(WatchedDirectory parent, string path, bool i try { - foreach (var childDir in Directory.GetDirectories(path, "*", ChildEnumerationOptions)) + foreach (string childDir in Directory.EnumerateDirectories(path, "*", ChildEnumerationOptions)) { if (!WatchChildDirectories(parent, childDir)) { @@ -1219,7 +1220,7 @@ internal void QueueEvent(WatcherEvent ev) internal void QueueError(Exception exception) { - if (IsStopped) + if (IsWatcherStopped) { return; }