Fix wrong behaviour when move multiple files out of watched folder on mac (dotnet...
authorJan Jahoda <jajahoda@microsoft.com>
Mon, 4 Nov 2019 16:07:14 +0000 (17:07 +0100)
committerGitHub <noreply@github.com>
Mon, 4 Nov 2019 16:07:14 +0000 (17:07 +0100)
* FS

watcher

* Multiple rename macOS fix

* Multiple rename macOS fix

* Format code

* Clean proj file

* Apply PR comments

* Apply suggestions from code review

Co-Authored-By: Cory Nelson <phrosty@gmail.com>
* Update src/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.OSX.cs

Co-Authored-By: Stephen Toub <stoub@microsoft.com>
* Apply PR comments

* Apply PR comments

* Apply PR comments

* Document rename event id behaviour

Commit migrated from https://github.com/dotnet/corefx/commit/a1f8c919ac1280bb235264d5cc81f23b0d117710

src/libraries/System.IO.FileSystem.Watcher/src/System.IO.FileSystem.Watcher.csproj
src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.OSX.cs
src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.Directory.Move.cs
src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs
src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs

index 8fd0908..2e54f36 100644 (file)
   <ItemGroup Condition="'$(TargetsUnix)' == 'true'">
     <Reference Include="System.Collections" />
   </ItemGroup>
-</Project>
\ No newline at end of file
+</Project>
index 7298e00..9c7ed89 100644 (file)
@@ -88,9 +88,9 @@ namespace System.IO
             // Always re-create the filter flags when start is called since they could have changed
             if ((flagsToTranslate & (NotifyFilters.Attributes | NotifyFilters.CreationTime | NotifyFilters.LastAccess | NotifyFilters.LastWrite | NotifyFilters.Size)) != 0)
             {
-                flags = FSEventStreamEventFlags.kFSEventStreamEventFlagItemInodeMetaMod  |
+                flags = FSEventStreamEventFlags.kFSEventStreamEventFlagItemInodeMetaMod |
                         FSEventStreamEventFlags.kFSEventStreamEventFlagItemFinderInfoMod |
-                        FSEventStreamEventFlags.kFSEventStreamEventFlagItemModified      |
+                        FSEventStreamEventFlags.kFSEventStreamEventFlagItemModified |
                         FSEventStreamEventFlags.kFSEventStreamEventFlagItemChangeOwner;
             }
             if ((flagsToTranslate & NotifyFilters.Security) != 0)
@@ -122,7 +122,7 @@ namespace System.IO
         {
             // Flags used to create the event stream
             private const Interop.EventStream.FSEventStreamCreateFlags EventStreamFlags = (Interop.EventStream.FSEventStreamCreateFlags.kFSEventStreamCreateFlagFileEvents |
-                                                                       Interop.EventStream.FSEventStreamCreateFlags.kFSEventStreamCreateFlagNoDefer   |
+                                                                       Interop.EventStream.FSEventStreamCreateFlags.kFSEventStreamCreateFlagNoDefer |
                                                                        Interop.EventStream.FSEventStreamCreateFlags.kFSEventStreamCreateFlagWatchRoot);
 
             // Weak reference to the associated watcher. A weak reference is used so that the FileSystemWatcher may be collected and finalized,
@@ -371,33 +371,33 @@ namespace System.IO
                 if (context is null)
                 {
                     // Flow suppressed, just run here
-                    ProcessEvents(numEvents.ToInt32(), eventPaths, eventFlags, eventIds, watcher);
+                    ProcessEvents(numEvents.ToInt32(), eventPaths, new Span<FSEventStreamEventFlags>(eventFlags, numEvents.ToInt32()), new Span<FSEventStreamEventId>(eventIds, numEvents.ToInt32()), watcher);
                 }
                 else
                 {
                     ExecutionContext.Run(
                         context,
-                        (object o) => ((RunningInstance)o).ProcessEvents(numEvents.ToInt32(), eventPaths, eventFlags, eventIds, watcher),
+                        (object o) => ((RunningInstance)o).ProcessEvents(numEvents.ToInt32(), eventPaths, new Span<FSEventStreamEventFlags>(eventFlags, numEvents.ToInt32()), new Span<FSEventStreamEventId>(eventIds, numEvents.ToInt32()), watcher),
                         this);
                 }
             }
 
             private unsafe void ProcessEvents(int numEvents,
                 byte** eventPaths,
-                FSEventStreamEventFlags* eventFlags,
-                FSEventStreamEventId* eventIds,
+                Span<FSEventStreamEventFlags> eventFlags,
+                Span<FSEventStreamEventId> eventIds,
                 FileSystemWatcher watcher)
             {
-                // Since renames come in pairs, when we find the first we need to search for the next one. Once we find it, we'll add it to this
-                // list so when the for-loop comes across it, we'll skip it since it's already been processed as part of the original of the pair.
-                List<FSEventStreamEventId> handledRenameEvents = null;
-                Memory<char>[] events = new Memory<char>[numEvents];
-                ParseEvents();
+                // Since renames come in pairs, when we reach the first we need to test for the next one if it is the case. If the next one belongs into the pair,
+                // we'll store the event id so when the for-loop comes across it, we'll skip it since it's already been processed as part of the original of the pair.
+                int? handledRenameEvents = null;
 
                 for (int i = 0; i < numEvents; i++)
                 {
-                    ReadOnlySpan<char> path = events[i].Span;
-                    Debug.Assert(path[path.Length - 1] != '/', "Trailing slashes on events is not supported");
+                    using ParsedEvent parsedEvent = ParseEvent(eventPaths[i]);
+
+                    ReadOnlySpan<char> path = parsedEvent.Path;
+                    Debug.Assert(path[^1] != '/', "Trailing slashes on events is not supported");
 
                     // Match Windows and don't notify us about changes to the Root folder
                     if (_fullDirectory.Length >= path.Length && path.Equals(_fullDirectory.AsSpan(0, path.Length), StringComparison.OrdinalIgnoreCase))
@@ -412,7 +412,7 @@ namespace System.IO
                         watcher.OnError(new ErrorEventArgs(new IOException(SR.FSW_BufferOverflow, (int)eventFlags[i])));
                         break;
                     }
-                    else if ((handledRenameEvents != null) && (handledRenameEvents.Contains(eventIds[i])))
+                    else if (handledRenameEvents == i)
                     {
                         // If this event is the second in a rename pair then skip it
                         continue;
@@ -443,15 +443,15 @@ namespace System.IO
                         }
                         if (((eventType & WatcherChangeTypes.Renamed) > 0))
                         {
-                            // Find the rename that is paired to this rename, which should be the next rename in the list
-                            int pairedId = FindRenameChangePairedChange(i, eventFlags, numEvents);
-                            if (pairedId == int.MinValue)
+                            // Find the rename that is paired to this rename.
+                            int? pairedId = FindRenameChangePairedChange(i, eventFlags, eventIds);
+                            if (!pairedId.HasValue)
                             {
                                 // Getting here means we have a rename without a pair, meaning it should be a create for the
                                 // move from unwatched folder to watcher folder scenario or a move from the watcher folder out.
                                 // Check if the item exists on disk to check which it is
                                 // Don't send a new notification if we already sent one for this event.
-                                if (DoesItemExist(path, IsFlagSet(eventFlags[i], FSEventStreamEventFlags.kFSEventStreamEventFlagItemIsFile)))
+                                if (DoesItemExist(path, eventFlags[i].HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagItemIsFile)))
                                 {
                                     if ((eventType & WatcherChangeTypes.Created) == 0)
                                     {
@@ -467,55 +467,63 @@ namespace System.IO
                             {
                                 // Remove the base directory prefix and add the paired event to the list of
                                 // events to skip and notify the user of the rename
-                                ReadOnlySpan<char> newPathRelativeName = events[pairedId].Span;
-                                if (newPathRelativeName.Length >= _fullDirectory.Length &&
-                                    newPathRelativeName.StartsWith(_fullDirectory, StringComparison.OrdinalIgnoreCase))
+                                using (ParsedEvent pairedEvent = ParseEvent(eventPaths[pairedId.GetValueOrDefault()]))
                                 {
-                                    newPathRelativeName = newPathRelativeName.Slice(_fullDirectory.Length);
-                                }
-                                watcher.NotifyRenameEventArgs(WatcherChangeTypes.Renamed, newPathRelativeName, relativePath);
+                                    ReadOnlySpan<char> newPathRelativeName = pairedEvent.Path;
+                                    if (newPathRelativeName.Length >= _fullDirectory.Length &&
+                                        newPathRelativeName.StartsWith(_fullDirectory, StringComparison.OrdinalIgnoreCase))
+                                    {
+                                        newPathRelativeName = newPathRelativeName.Slice(_fullDirectory.Length);
+                                    }
 
-                                // Create a new list, if necessary, and add the event
-                                if (handledRenameEvents == null)
-                                {
-                                    handledRenameEvents = new List<FSEventStreamEventId>();
+                                    watcher.NotifyRenameEventArgs(WatcherChangeTypes.Renamed, newPathRelativeName, relativePath);
                                 }
-                                handledRenameEvents.Add(eventIds[pairedId]);
+                                handledRenameEvents = pairedId.GetValueOrDefault();
                             }
                         }
                     }
-
-                    ArraySegment<char> underlyingArray;
-                    if (MemoryMarshal.TryGetArray(events[i], out underlyingArray))
-                        ArrayPool<char>.Shared.Return(underlyingArray.Array);
                 }
 
                 this._context = ExecutionContext.Capture();
 
-                void ParseEvents()
+                ParsedEvent ParseEvent(byte* nativeEventPath)
                 {
-                    for (int i = 0; i < events.Length; i++)
+                    int byteCount = 0;
+                    Debug.Assert(nativeEventPath != null);
+                    byte* temp = nativeEventPath;
+
+                    // Finds the position of null character.
+                    while (*temp != 0)
                     {
-                        int byteCount = 0;
-                        Debug.Assert(eventPaths[i] != null);
-                        byte* temp = eventPaths[i];
+                        temp++;
+                        byteCount++;
+                    }
 
-                        // Finds the position of null character.
-                        while (*temp != 0)
-                        {
-                            temp++;
-                            byteCount++;
-                        }
+                    Debug.Assert(byteCount > 0, "Empty events are not supported");
+                    char[] tempBuffer = ArrayPool<char>.Shared.Rent(Encoding.UTF8.GetMaxCharCount(byteCount));
+
+                    // Converting an array of bytes to UTF-8 char array
+                    int charCount = Encoding.UTF8.GetChars(new ReadOnlySpan<byte>(nativeEventPath, byteCount), tempBuffer);
+                    return new ParsedEvent(tempBuffer.AsSpan(0, charCount), tempBuffer);
+                }
 
-                        Debug.Assert(byteCount > 0, "Empty events are not supported");
-                        events[i] = new Memory<char>(ArrayPool<char>.Shared.Rent(Encoding.UTF8.GetMaxCharCount(byteCount)));
-                        int charCount;
+            }
 
-                        // Converting an array of bytes to UTF-8 char array
-                        charCount = Encoding.UTF8.GetChars(new ReadOnlySpan<byte>(eventPaths[i], byteCount), events[i].Span);
-                        events[i] = events[i].Slice(0, charCount);
-                    }
+            private readonly ref struct ParsedEvent
+            {
+
+                public ParsedEvent(ReadOnlySpan<char> path, char[] tempBuffer)
+                {
+                    TempBuffer = tempBuffer;
+                    Path = path;
                 }
+
+                public readonly ReadOnlySpan<char> Path;
+
+                public readonly char[] TempBuffer;
+
+                public void Dispose() => ArrayPool<char>.Shared.Return(TempBuffer);
+
             }
 
             /// <summary>
@@ -547,15 +555,15 @@ namespace System.IO
                 if (eventIsCorrectType || ((allowDirs || allowFiles) && (eventIsLink)))
                 {
                     // Notify Created/Deleted/Renamed events.
-                    if (IsFlagSet(eventFlags, FSEventStreamEventFlags.kFSEventStreamEventFlagItemRenamed))
+                    if (eventFlags.HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagItemRenamed))
                     {
                         eventType |= WatcherChangeTypes.Renamed;
                     }
-                    if (IsFlagSet(eventFlags, FSEventStreamEventFlags.kFSEventStreamEventFlagItemCreated))
+                    if (eventFlags.HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagItemCreated))
                     {
                         eventType |= WatcherChangeTypes.Created;
                     }
-                    if (IsFlagSet(eventFlags, FSEventStreamEventFlags.kFSEventStreamEventFlagItemRemoved))
+                    if (eventFlags.HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagItemRemoved))
                     {
                         eventType |= WatcherChangeTypes.Deleted;
                     }
@@ -566,12 +574,12 @@ namespace System.IO
             private bool ShouldRescanOccur(FSEventStreamEventFlags flags)
             {
                 // Check if any bit is set that signals that the caller should rescan
-                return (IsFlagSet(flags, FSEventStreamEventFlags.kFSEventStreamEventFlagMustScanSubDirs) ||
-                        IsFlagSet(flags, FSEventStreamEventFlags.kFSEventStreamEventFlagUserDropped)     ||
-                        IsFlagSet(flags, FSEventStreamEventFlags.kFSEventStreamEventFlagKernelDropped)   ||
-                        IsFlagSet(flags, FSEventStreamEventFlags.kFSEventStreamEventFlagRootChanged)     ||
-                        IsFlagSet(flags, FSEventStreamEventFlags.kFSEventStreamEventFlagMount)           ||
-                        IsFlagSet(flags, FSEventStreamEventFlags.kFSEventStreamEventFlagUnmount));
+                return (flags.HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagMustScanSubDirs) ||
+                        flags.HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagUserDropped) ||
+                        flags.HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagKernelDropped) ||
+                        flags.HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagRootChanged) ||
+                        flags.HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagMount) ||
+                        flags.HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagUnmount));
             }
 
             private bool CheckIfPathIsNested(ReadOnlySpan<char> eventPath)
@@ -582,36 +590,36 @@ namespace System.IO
                 return _includeChildren || _fullDirectory.AsSpan().StartsWith(System.IO.Path.GetDirectoryName(eventPath), StringComparison.OrdinalIgnoreCase);
             }
 
-            private unsafe int FindRenameChangePairedChange(
+            private unsafe int? FindRenameChangePairedChange(
                 int currentIndex,
-                FSEventStreamEventFlags* eventFlags,
-                int numEvents)
+                Span<FSEventStreamEventFlags> flags, Span<FSEventStreamEventId> ids)
             {
-                // Start at one past the current index and try to find the next Rename item, which should be the old path.
-                for (int i = currentIndex + 1; i < numEvents; i++)
+                // The rename event can be composed of two events. The first contains the original file name the second contains the new file name.
+                // Each of the events is delivered only when the corresponding folder is watched. It means both events are delivered when the rename/move
+                // occurs inside the watched folder. When the move has origin o final destination outside, only one event is delivered. To distinguish
+                // between two nonrelated events and the event which belong together the event ID is tested. Only related rename events differ in ID by one.
+                // This behavior isn't documented and there is an open radar http://www.openradar.me/13461247.
+
+                int nextIndex = currentIndex + 1;
+
+                if (nextIndex >= flags.Length)
+                    return null;
+
+                if (ids[currentIndex] + 1 == ids[nextIndex] &&
+                    flags[nextIndex].HasFlag(FSEventStreamEventFlags.kFSEventStreamEventFlagItemRenamed))
                 {
-                    if (IsFlagSet(eventFlags[i], FSEventStreamEventFlags.kFSEventStreamEventFlagItemRenamed))
-                    {
-                        // We found match, stop looking
-                        return i;
-                    }
+                    return nextIndex;
                 }
 
-                return int.MinValue;
+                return null;
             }
-
-            private static bool IsFlagSet(FSEventStreamEventFlags flags, FSEventStreamEventFlags value)
-            {
-                return (value & flags) == value;
-            }
-
             private static bool DoesItemExist(ReadOnlySpan<char> path, bool isFile)
             {
                 if (path.IsEmpty || path.Length == 0)
                     return false;
 
                 if (!isFile)
-                    return  FileSystem.DirectoryExists(path);
+                    return FileSystem.DirectoryExists(path);
 
                 return PathInternal.IsDirectorySeparator(path[path.Length - 1])
                     ? false
index 654cec1..bf8da6b 100644 (file)
@@ -4,6 +4,10 @@
 
 using Xunit;
 
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+
 namespace System.IO.Tests
 {
     public class Directory_Move_Tests : FileSystemWatcherTest
@@ -16,11 +20,43 @@ namespace System.IO.Tests
         }
 
         [Fact]
+        [PlatformSpecific(TestPlatforms.OSX)]
         public void Directory_Move_From_Watched_To_Unwatched()
         {
             DirectoryMove_FromWatchedToUnwatched(WatcherChangeTypes.Deleted);
         }
 
+        [Theory]
+        [PlatformSpecific(TestPlatforms.OSX)]
+        [InlineData(1)]
+        [InlineData(2)]
+        [InlineData(3)]
+        public void Directory_Move_Multiple_From_Watched_To_Unwatched_Mac(int filesCount)
+        {
+            // On Mac, the FSStream aggregate old events caused by the test setup.
+            // There is no option how to get rid of it but skip it.
+            DirectoryMove_Multiple_FromWatchedToUnwatched(filesCount, skipOldEvents: true);
+        }
+
+        [Theory]
+        [PlatformSpecific(~TestPlatforms.OSX)]
+        [InlineData(1)]
+        [InlineData(2)]
+        [InlineData(3)]
+        public void Directory_Move_Multiple_From_Watched_To_Unwatched(int filesCount)
+        {
+            DirectoryMove_Multiple_FromWatchedToUnwatched(filesCount, skipOldEvents: false);
+        }
+
+        [Theory]
+        [InlineData(1)]
+        [InlineData(2)]
+        [InlineData(3)]
+        public void Directory_Move_Multiple_From_Unatched_To_Watched(int filesCount)
+        {
+            DirectoryMove_Multiple_FromUnwatchedToWatched(filesCount);
+        }
+
         [Fact]
         [PlatformSpecific(TestPlatforms.Windows)]  // Expected WatcherChangeTypes are different based on OS
         public void Windows_Directory_Move_To_Different_Watched_Directory()
@@ -92,6 +128,71 @@ namespace System.IO.Tests
             }
         }
 
+        private void DirectoryMove_Multiple_FromWatchedToUnwatched(int filesCount, bool skipOldEvents)
+        {
+            Assert.InRange(filesCount, 0, int.MaxValue);
+
+            using var watchedTestDirectory = new TempDirectory(GetTestFilePath());
+            using var unwatchedTestDirectory = new TempDirectory(GetTestFilePath());
+
+            var dirs = Enumerable.Range(0, filesCount)
+                            .Select(i => new
+                            {
+                                DirecoryInWatchedDir = Path.Combine(watchedTestDirectory.Path, $"dir{i}"),
+                                DirecoryInUnwatchedDir = Path.Combine(unwatchedTestDirectory.Path, $"dir{i}")
+                            }).ToArray();
+
+            Array.ForEach(dirs, (dir) => Directory.CreateDirectory(dir.DirecoryInWatchedDir));
+
+            using var watcher = new FileSystemWatcher(watchedTestDirectory.Path, "*");
+
+            Action action = () => Array.ForEach(dirs, dir => Directory.Move(dir.DirecoryInWatchedDir, dir.DirecoryInUnwatchedDir));
+
+            // On macOS, for each file we receive two events as describe in comment below.
+            int expectEvents = filesCount;
+            if (skipOldEvents)
+                expectEvents = expectEvents * 2;
+
+            IEnumerable<FiredEvent> events = ExpectEvents(watcher, expectEvents, action);
+
+            if (skipOldEvents)
+                events = events.Where(x => x.EventType != WatcherChangeTypes.Created);
+
+            var expectedEvents = dirs.Select(dir => new FiredEvent(WatcherChangeTypes.Deleted, dir.DirecoryInWatchedDir));
+
+            // Remove Created events as there is racecondition when create dir and then observe parent folder. It receives Create event altought Watcher is not registered yet.
+            Assert.Equal(expectedEvents, events.Where(x => x.EventType != WatcherChangeTypes.Created));
+
+
+        }
+
+        private void DirectoryMove_Multiple_FromUnwatchedToWatched(int filesCount)
+        {
+            Assert.InRange(filesCount, 0, int.MaxValue);
+
+            using var watchedTestDirectory = new TempDirectory(GetTestFilePath());
+            using var unwatchedTestDirectory = new TempDirectory(GetTestFilePath());
+
+
+            var dirs = Enumerable.Range(0, filesCount)
+                            .Select(i => new
+                            {
+                                DirecoryInWatchedDir = Path.Combine(watchedTestDirectory.Path, $"dir{i}"),
+                                DirecoryInUnwatchedDir = Path.Combine(unwatchedTestDirectory.Path, $"dir{i}")
+                            }).ToArray();
+
+            Array.ForEach(dirs, (dir) => Directory.CreateDirectory(dir.DirecoryInUnwatchedDir));
+
+            using var watcher = new FileSystemWatcher(watchedTestDirectory.Path, "*");
+
+            Action action = () => Array.ForEach(dirs, dir => Directory.Move(dir.DirecoryInUnwatchedDir, dir.DirecoryInWatchedDir));
+
+            List<FiredEvent> events = ExpectEvents(watcher, filesCount, action);
+            var expectedEvents = dirs.Select(dir => new FiredEvent(WatcherChangeTypes.Created, dir.DirecoryInWatchedDir));
+
+            Assert.Equal(expectedEvents, events);
+        }
+
         private void DirectoryMove_FromWatchedToUnwatched(WatcherChangeTypes eventType)
         {
             using (var watchedTestDirectory = new TempDirectory(GetTestFilePath()))
@@ -168,4 +269,4 @@ namespace System.IO.Tests
 
         #endregion
     }
-}
\ No newline at end of file
+}
index b9d5d97..6347d56 100644 (file)
@@ -2,6 +2,8 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 // See the LICENSE file in the project root for more information.
 
+using System.Collections.Generic;
+using System.Linq;
 using System.Runtime.InteropServices;
 using Xunit;
 
@@ -29,6 +31,37 @@ namespace System.IO.Tests
             FileMove_FromWatchedToUnwatched(WatcherChangeTypes.Deleted);
         }
 
+        [Theory]
+        [PlatformSpecific(TestPlatforms.OSX)]
+        [InlineData(1)]
+        [InlineData(2)]
+        [InlineData(3)]
+        public void File_Move_Multiple_From_Watched_To_Unwatched_Mac(int filesCount)
+        {
+            // On Mac, the FSStream aggregate old events caused by the test setup.
+            // There is no option how to get rid of it but skip it.
+            FileMove_Multiple_FromWatchedToUnwatched(filesCount, skipOldEvents: true);
+        }
+
+        [Theory]
+        [PlatformSpecific(~TestPlatforms.OSX)]
+        [InlineData(1)]
+        [InlineData(2)]
+        [InlineData(3)]
+        public void File_Move_From_Watched_To_Unwatched(int filesCount)
+        {
+            FileMove_Multiple_FromWatchedToUnwatched(filesCount, skipOldEvents: false);
+        }
+
+        [Theory]
+        [InlineData(1)]
+        [InlineData(2)]
+        [InlineData(3)]
+        public void File_Move_Multiple_From_Unwatched_To_WatchedMac(int filesCount)
+        {
+            FileMove_Multiple_FromUnwatchedToWatched(filesCount);
+        }
+
         [Fact]
         [PlatformSpecific(TestPlatforms.Windows)]  // Expected WatcherChangeTypes are different based on OS
         public void Windows_File_Move_To_Different_Watched_Directory()
@@ -152,6 +185,74 @@ namespace System.IO.Tests
             }
         }
 
+        private void FileMove_Multiple_FromWatchedToUnwatched(int filesCount, bool skipOldEvents)
+        {
+            Assert.InRange(filesCount, 0, int.MaxValue);
+
+            using var testDirectory = new TempDirectory(GetTestFilePath());
+            using var watchedTestDirectory = new TempDirectory(Path.Combine(testDirectory.Path, "dir_watched"));
+            using var unwatchedTestDirectory = new TempDirectory(Path.Combine(testDirectory.Path, "dir_unwatched"));
+
+            var files = Enumerable.Range(0, filesCount)
+                            .Select(i => new
+                            {
+                                FileInWatchedDir = Path.Combine(watchedTestDirectory.Path, $"file{i}"),
+                                FileInUnwatchedDir = Path.Combine(unwatchedTestDirectory.Path, $"file{i}")
+                            }).ToArray();
+
+            Array.ForEach(files, (file) => File.Create(file.FileInWatchedDir).Dispose());
+
+            using var watcher = new FileSystemWatcher(watchedTestDirectory.Path, "*");
+
+            Action action = () => Array.ForEach(files, file => File.Move(file.FileInWatchedDir, file.FileInUnwatchedDir));
+
+            // On macOS, for each file we receive two events as describe in comment below.
+            int expectEvents = filesCount;
+            if (skipOldEvents)
+            {
+                expectEvents = expectEvents * 3;
+            }
+
+            IEnumerable<FiredEvent> events = ExpectEvents(watcher, expectEvents, action);
+
+            // Remove Created and Changed events as there is racecondition when create file and then observe parent folder. It receives Create and Changed event altought Watcher is not registered yet.
+            if (skipOldEvents)
+            {
+                events = events.Where(x => (x.EventType & (WatcherChangeTypes.Created | WatcherChangeTypes.Changed)) == 0);
+            }
+
+            var expectedEvents = files.Select(file => new FiredEvent(WatcherChangeTypes.Deleted, file.FileInWatchedDir));
+
+            Assert.Equal(expectedEvents, events);
+        }
+
+        private void FileMove_Multiple_FromUnwatchedToWatched(int filesCount)
+        {
+            Assert.InRange(filesCount, 0, int.MaxValue);
+
+            using var testDirectory = new TempDirectory(GetTestFilePath());
+            using var watchedTestDirectory = new TempDirectory(Path.Combine(testDirectory.Path, "dir_watched"));
+            using var unwatchedTestDirectory = new TempDirectory(Path.Combine(testDirectory.Path, "dir_unwatched"));
+
+            var files = Enumerable.Range(0, filesCount)
+                            .Select(i => new
+                            {
+                                FileInWatchedDir = Path.Combine(watchedTestDirectory.Path, $"file{i}"),
+                                FileInUnwatchedDir = Path.Combine(unwatchedTestDirectory.Path, $"file{i}")
+                            }).ToArray();
+
+            Array.ForEach(files, (file) => File.Create(file.FileInUnwatchedDir).Dispose());
+
+            using var watcher = new FileSystemWatcher(watchedTestDirectory.Path, "*");
+
+            Action action = () => Array.ForEach(files, file => File.Move(file.FileInUnwatchedDir, file.FileInWatchedDir));
+
+            List<FiredEvent> events = ExpectEvents(watcher, filesCount, action);
+            var expectedEvents = files.Select(file => new FiredEvent(WatcherChangeTypes.Created, file.FileInWatchedDir));
+
+            Assert.Equal(expectedEvents, events);
+        }
+
         private void FileMove_FromUnwatchedToWatched(WatcherChangeTypes eventType)
         {
             using (var testDirectory = new TempDirectory(GetTestFilePath()))
index 4f100b8..af5fc09 100644 (file)
@@ -491,5 +491,82 @@ namespace System.IO.Tests
 
             return newWatcher;
         }
+
+        internal readonly struct FiredEvent
+        {
+            public FiredEvent(WatcherChangeTypes eventType, string dir1, string dir2 = "") => (EventType, Dir1, Dir2) = (eventType, dir1, dir2);
+
+            public readonly WatcherChangeTypes EventType;
+            public readonly string Dir1;
+            public readonly string Dir2;
+
+            public override bool Equals(object obj) => obj is FiredEvent evt && Equals(evt);
+
+            public bool Equals(FiredEvent other) => EventType == other.EventType &&
+                Dir1 == other.Dir1 &&
+                Dir2 == other.Dir2;
+
+
+            public override int GetHashCode() => EventType.GetHashCode() ^ Dir1.GetHashCode() ^ Dir2.GetHashCode();
+
+            public override string ToString() => $"{EventType} {Dir1} {Dir2}";
+
+        }
+
+        // Observe until an expected count of events is triggered, otherwise fail. Return all collected events.
+        internal static List<FiredEvent> ExpectEvents(FileSystemWatcher watcher, int expectedEvents, Action action)
+        {
+            using var eventsOccured = new AutoResetEvent(false);
+            var eventsOrrures = 0;
+
+            var events = new List<FiredEvent>();
+
+            ErrorEventArgs error = null;
+
+            FileSystemEventHandler fileWatcherEvent = (_, e) => AddEvent(e.ChangeType, e.FullPath);
+            RenamedEventHandler renameWatcherEvent = (_, e) => AddEvent(e.ChangeType, e.FullPath, e.OldFullPath);
+            ErrorEventHandler errorHandler = (_, e) => error ??= e ?? new ErrorEventArgs(null);
+
+            watcher.Changed += fileWatcherEvent;
+            watcher.Created += fileWatcherEvent;
+            watcher.Deleted += fileWatcherEvent;
+            watcher.Renamed += renameWatcherEvent;
+            watcher.Error += errorHandler;
+
+            bool raisingEvent = watcher.EnableRaisingEvents;
+            watcher.EnableRaisingEvents = true;
+
+            try
+            {
+                action();
+                eventsOccured.WaitOne(new TimeSpan(0, 0, 5));
+            }
+            finally
+            {
+                watcher.Changed -= fileWatcherEvent;
+                watcher.Created -= fileWatcherEvent;
+                watcher.Deleted -= fileWatcherEvent;
+                watcher.Renamed -= renameWatcherEvent;
+                watcher.Error -= errorHandler;
+                watcher.EnableRaisingEvents = raisingEvent;
+            }
+
+            if (error != null)
+            {
+                Assert.False(true, $"Filewatcher error event triggered: { error.GetException()?.Message ?? "Unknow error" }");
+            }
+            Assert.True(eventsOrrures == expectedEvents, $"Expected events ({expectedEvents}) count doesn't match triggered events count ({eventsOrrures}):{Environment.NewLine}{String.Join(Environment.NewLine, events)}");
+
+            return events;
+
+            void AddEvent(WatcherChangeTypes eventType, string dir1, string dir2 = "")
+            {
+                events.Add(new FiredEvent(eventType, dir1, dir2));
+                if (Interlocked.Increment(ref eventsOrrures) == expectedEvents)
+                {
+                    eventsOccured.Set();
+                }
+            }
+        }
     }
 }