diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs index fe56edee2085..c7361c360581 100644 --- a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs @@ -79,7 +79,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke using var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, currentRunCancellationSource.Token); using var fileSetWatcher = new FileWatcher(Context.Reporter); - fileSetWatcher.WatchContainingDirectories(evaluationResult.Files.Keys); + fileSetWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true); var processTask = ProcessRunner.RunAsync(processSpec, Context.Reporter, isUserApplication: true, launchResult: null, combinedCancellationSource.Token); diff --git a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs index 46541c5f7b9c..9794f7fe1f90 100644 --- a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs @@ -180,7 +180,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke return; } - fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys); + fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true); var changedFilesAccumulator = ImmutableList.Empty; @@ -441,7 +441,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableDict evaluationResult = await EvaluateRootProjectAsync(iterationCancellationToken); // additional directories may have been added: - fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys); + fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true); await compilationHandler.Workspace.UpdateProjectConeAsync(RootFileSetFactory.RootProjectFile, iterationCancellationToken); @@ -554,7 +554,7 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche { if (!fileWatcher.WatchingDirectories) { - fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys); + fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true); } _ = await fileWatcher.WaitForFileChangeAsync( @@ -564,8 +564,8 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche } else { - // evaluation cancelled - watch for any changes in the directory containing the root project: - fileWatcher.WatchContainingDirectories([RootFileSetFactory.RootProjectFile]); + // evaluation cancelled - watch for any changes in the directory tree containing the root project: + fileWatcher.WatchContainingDirectories([RootFileSetFactory.RootProjectFile], includeSubdirectories: true); _ = await fileWatcher.WaitForFileChangeAsync( acceptChange: change => AcceptChange(change), @@ -605,12 +605,6 @@ private bool AcceptChange(ChangedPath change) { var (path, kind) = change; - // only handle file changes: - if (Directory.Exists(path)) - { - return false; - } - if (PathUtilities.GetContainingDirectories(path).FirstOrDefault(IsHiddenDirectory) is { } containingHiddenDir) { Context.Reporter.Report(MessageDescriptor.IgnoringChangeInHiddenDirectory, containingHiddenDir, kind, path); diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs index 5f4c0584702c..e0e80d601c2c 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs @@ -5,8 +5,13 @@ namespace Microsoft.DotNet.Watch { internal sealed class FileWatcher(IReporter reporter) : IDisposable { - // Directory watcher for each watched directory - private readonly Dictionary _watchers = []; + // Directory watcher for each watched directory tree. + // Keyed by full path to the root directory with a trailing directory separator. + private readonly Dictionary _directoryTreeWatchers = new(PathUtilities.OSSpecificPathComparer); + + // Directory watcher for each watched directory (non-recursive). + // Keyed by full path to the root directory with a trailing directory separator. + private readonly Dictionary _directoryWatchers = new(PathUtilities.OSSpecificPathComparer); private bool _disposed; public event Action? OnFileChange; @@ -22,7 +27,7 @@ public void Dispose() _disposed = true; - foreach (var (_, watcher) in _watchers) + foreach (var (_, watcher) in _directoryTreeWatchers) { watcher.OnFileChange -= WatcherChangedHandler; watcher.OnError -= WatcherErrorHandler; @@ -31,39 +36,33 @@ public void Dispose() } public bool WatchingDirectories - => _watchers.Count > 0; + => _directoryTreeWatchers.Count > 0 || _directoryWatchers.Count > 0; - public void WatchContainingDirectories(IEnumerable filePaths) - => WatchDirectories(filePaths.Select(path => Path.GetDirectoryName(path)!)); + public void WatchContainingDirectories(IEnumerable filePaths, bool includeSubdirectories) + => WatchDirectories(filePaths.Select(path => Path.GetDirectoryName(path)!), includeSubdirectories); - public void WatchDirectories(IEnumerable directories) + public void WatchDirectories(IEnumerable directories, bool includeSubdirectories) { ObjectDisposedException.ThrowIf(_disposed, this); - foreach (var dir in directories) + foreach (var dir in directories.Distinct()) { - var directory = EnsureTrailingSlash(dir); - - var alreadyWatched = _watchers - .Where(d => directory.StartsWith(d.Key)) - .Any(); + var directory = PathUtilities.EnsureTrailingSlash(PathUtilities.NormalizeDirectorySeparators(dir)); - if (alreadyWatched) + // the directory is watched by active directory watcher: + if (!includeSubdirectories && _directoryWatchers.ContainsKey(directory)) { continue; } - var redundantWatchers = _watchers - .Where(d => d.Key.StartsWith(directory)) - .Select(d => d.Key) - .ToList(); - - foreach (var watcher in redundantWatchers) + // the directory is a root or subdirectory of active directory tree watcher: + var alreadyWatched = _directoryTreeWatchers.Any(d => directory.StartsWith(d.Key, PathUtilities.OSSpecificPathComparison)); + if (alreadyWatched) { - DisposeWatcher(watcher); + continue; } - var newWatcher = FileWatcherFactory.CreateWatcher(directory); + var newWatcher = FileWatcherFactory.CreateWatcher(directory, includeSubdirectories); if (newWatcher is EventBasedDirectoryWatcher eventBasedWatcher) { eventBasedWatcher.Logger = message => reporter.Verbose(message); @@ -73,7 +72,30 @@ public void WatchDirectories(IEnumerable directories) newWatcher.OnError += WatcherErrorHandler; newWatcher.EnableRaisingEvents = true; - _watchers.Add(directory, newWatcher); + // watchers that are now redundant (covered by the new directory watcher): + if (includeSubdirectories) + { + var watchersToRemove = _directoryTreeWatchers + .Where(d => d.Key.StartsWith(directory, PathUtilities.OSSpecificPathComparison)) + .ToList(); + + foreach (var (watchedDirectory, watcher) in watchersToRemove) + { + _directoryTreeWatchers.Remove(watchedDirectory); + + watcher.EnableRaisingEvents = false; + watcher.OnFileChange -= WatcherChangedHandler; + watcher.OnError -= WatcherErrorHandler; + + watcher.Dispose(); + } + + _directoryTreeWatchers.Add(directory, newWatcher); + } + else + { + _directoryWatchers.Add(directory, newWatcher); + } } } @@ -93,21 +115,6 @@ private void WatcherChangedHandler(object? sender, ChangedPath change) } } - private void DisposeWatcher(string directory) - { - var watcher = _watchers[directory]; - _watchers.Remove(directory); - - watcher.EnableRaisingEvents = false; - watcher.OnFileChange -= WatcherChangedHandler; - watcher.OnError -= WatcherErrorHandler; - - watcher.Dispose(); - } - - private static string EnsureTrailingSlash(string path) - => (path is [.., var last] && last != Path.DirectorySeparatorChar) ? path + Path.DirectorySeparatorChar : path; - public async Task WaitForFileChangeAsync(IReadOnlyDictionary fileSet, Action? startedWatching, CancellationToken cancellationToken) { var changedPath = await WaitForFileChangeAsync( @@ -151,7 +158,7 @@ public static async ValueTask WaitForFileChangeAsync(string filePath, IReporter { using var watcher = new FileWatcher(reporter); - watcher.WatchDirectories([Path.GetDirectoryName(filePath)!]); + watcher.WatchContainingDirectories([filePath], includeSubdirectories: false); var fileChange = await watcher.WaitForFileChangeAsync( acceptChange: change => change.Path == filePath, diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs index 4abf152e5e10..79e54c3b401f 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs @@ -8,22 +8,21 @@ namespace Microsoft.DotNet.Watch internal sealed class EventBasedDirectoryWatcher : IDirectoryWatcher { public event EventHandler? OnFileChange; - public event EventHandler? OnError; public string WatchedDirectory { get; } - - internal Action? Logger { get; set; } + public bool IncludeSubdirectories { get; } + public Action? Logger { get; set; } private volatile bool _disposed; - private FileSystemWatcher? _fileSystemWatcher; + private readonly Lock _createLock = new(); - private readonly object _createLock = new(); - - internal EventBasedDirectoryWatcher(string watchedDirectory) + internal EventBasedDirectoryWatcher(string watchedDirectory, bool includeSubdirectories) { WatchedDirectory = watchedDirectory; + IncludeSubdirectories = includeSubdirectories; + CreateFileSystemWatcher(); } @@ -40,7 +39,7 @@ private void WatcherErrorHandler(object sender, ErrorEventArgs e) return; } - Logger?.Invoke("Error"); + Logger?.Invoke("[FW] Error"); var exception = e.GetException(); @@ -65,14 +64,11 @@ private void WatcherRenameHandler(object sender, RenamedEventArgs e) return; } - Logger?.Invoke($"Renamed '{e.OldFullPath}' to '{e.FullPath}'."); - - NotifyChange(e.OldFullPath, ChangeKind.Delete); - NotifyChange(e.FullPath, ChangeKind.Add); + Logger?.Invoke($"[FW] Renamed '{e.OldFullPath}' to '{e.FullPath}'."); if (Directory.Exists(e.FullPath)) { - foreach (var newLocation in Directory.EnumerateFileSystemEntries(e.FullPath, "*", SearchOption.AllDirectories)) + foreach (var newLocation in Directory.EnumerateFiles(e.FullPath, "*", SearchOption.AllDirectories)) { // Calculated previous path of this moved item. var oldLocation = Path.Combine(e.OldFullPath, newLocation.Substring(e.FullPath.Length + 1)); @@ -80,6 +76,11 @@ private void WatcherRenameHandler(object sender, RenamedEventArgs e) NotifyChange(newLocation, ChangeKind.Add); } } + else + { + NotifyChange(e.OldFullPath, ChangeKind.Delete); + NotifyChange(e.FullPath, ChangeKind.Add); + } } private void WatcherDeletedHandler(object sender, FileSystemEventArgs e) @@ -89,7 +90,16 @@ private void WatcherDeletedHandler(object sender, FileSystemEventArgs e) return; } - Logger?.Invoke($"Deleted '{e.FullPath}'."); + var isDir = Directory.Exists(e.FullPath); + + Logger?.Invoke($"[FW] Deleted '{e.FullPath}'."); + + // ignore directory changes: + if (isDir) + { + return; + } + NotifyChange(e.FullPath, ChangeKind.Delete); } @@ -100,7 +110,16 @@ private void WatcherChangeHandler(object sender, FileSystemEventArgs e) return; } - Logger?.Invoke($"Updated '{e.FullPath}'."); + var isDir = Directory.Exists(e.FullPath); + + Logger?.Invoke($"[FW] Updated '{e.FullPath}'."); + + // ignore directory changes: + if (isDir) + { + return; + } + NotifyChange(e.FullPath, ChangeKind.Update); } @@ -111,7 +130,15 @@ private void WatcherAddedHandler(object sender, FileSystemEventArgs e) return; } - Logger?.Invoke($"Added '{e.FullPath}'."); + var isDir = Directory.Exists(e.FullPath); + + Logger?.Invoke($"[FW] Added '{e.FullPath}'."); + + if (isDir) + { + return; + } + NotifyChange(e.FullPath, ChangeKind.Add); } @@ -136,7 +163,7 @@ private void CreateFileSystemWatcher() _fileSystemWatcher = new FileSystemWatcher(WatchedDirectory) { - IncludeSubdirectories = true + IncludeSubdirectories = IncludeSubdirectories }; _fileSystemWatcher.Created += WatcherAddedHandler; diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/FileWatcherFactory.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/FileWatcherFactory.cs index 7baa775806eb..0da61b49a7ab 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/FileWatcherFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/FileWatcherFactory.cs @@ -5,14 +5,14 @@ namespace Microsoft.DotNet.Watch { internal static class FileWatcherFactory { - public static IDirectoryWatcher CreateWatcher(string watchedDirectory) - => CreateWatcher(watchedDirectory, EnvironmentVariables.IsPollingEnabled); + public static IDirectoryWatcher CreateWatcher(string watchedDirectory, bool includeSubdirectories) + => CreateWatcher(watchedDirectory, EnvironmentVariables.IsPollingEnabled, includeSubdirectories); - public static IDirectoryWatcher CreateWatcher(string watchedDirectory, bool usePollingWatcher) + public static IDirectoryWatcher CreateWatcher(string watchedDirectory, bool usePollingWatcher, bool includeSubdirectories) { return usePollingWatcher ? - new PollingDirectoryWatcher(watchedDirectory) : - new EventBasedDirectoryWatcher(watchedDirectory); + new PollingDirectoryWatcher(watchedDirectory, includeSubdirectories) : + new EventBasedDirectoryWatcher(watchedDirectory, includeSubdirectories); } } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs index 4adff49b1425..1aa00d985281 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs @@ -1,16 +1,18 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.DotNet.Watch +namespace Microsoft.DotNet.Watch; + +/// +/// Watches for changes in a and its subdirectories. +/// +internal interface IDirectoryWatcher : IDisposable { - internal interface IDirectoryWatcher : IDisposable - { - event EventHandler OnFileChange; + event EventHandler OnFileChange; - event EventHandler OnError; + event EventHandler OnError; - string WatchedDirectory { get; } + string WatchedDirectory { get; } - bool EnableRaisingEvents { get; set; } - } + bool EnableRaisingEvents { get; set; } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs index a462944e3361..86ba4084e03a 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs @@ -11,10 +11,13 @@ internal sealed class PollingDirectoryWatcher : IDirectoryWatcher private static readonly TimeSpan _minRunInternal = TimeSpan.FromSeconds(.5); private readonly DirectoryInfo _watchedDirectory; + private readonly bool _includeSubdirectories; - private Dictionary _knownEntities = []; - private Dictionary _tempDictionary = []; - private readonly Dictionary _changes = []; + private Dictionary _currentSnapshot = new(PathUtilities.OSSpecificPathComparer); + + // The following are sets that are used to calculate new snapshot and cleared on eached use (pooled): + private Dictionary _snapshotBuilder = new(PathUtilities.OSSpecificPathComparer); + private readonly Dictionary _changesBuilder = new(PathUtilities.OSSpecificPathComparer); private Thread _pollingThread; private bool _raiseEvents; @@ -29,11 +32,10 @@ internal sealed class PollingDirectoryWatcher : IDirectoryWatcher public string WatchedDirectory { get; } - public PollingDirectoryWatcher(string watchedDirectory) + public PollingDirectoryWatcher(string watchedDirectory, bool includeSubdirectories) { - Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory)); - _watchedDirectory = new DirectoryInfo(watchedDirectory); + _includeSubdirectories = includeSubdirectories; WatchedDirectory = _watchedDirectory.FullName; _pollingThread = new Thread(new ThreadStart(PollingLoop)) @@ -42,7 +44,7 @@ public PollingDirectoryWatcher(string watchedDirectory) Name = nameof(PollingDirectoryWatcher) }; - CreateKnownFilesSnapshot(); + CaptureInitialSnapshot(); _pollingThread.Start(); } @@ -91,90 +93,53 @@ private void PollingLoop() stopwatch.Stop(); } - private void CreateKnownFilesSnapshot() + private void CaptureInitialSnapshot() { - _knownEntities.Clear(); + Debug.Assert(_currentSnapshot.Count == 0); - ForeachEntityInDirectory(_watchedDirectory, fileInfo => + ForeachEntityInDirectory(_watchedDirectory, (filePath, writeTime) => { - _knownEntities.Add(fileInfo.FullName, new FileMeta(fileInfo, foundAgain: false)); + _currentSnapshot.Add(filePath, writeTime); }); } private void CheckForChangedFiles() { - _changes.Clear(); + Debug.Assert(_changesBuilder.Count == 0); + Debug.Assert(_snapshotBuilder.Count == 0); - ForeachEntityInDirectory(_watchedDirectory, fileInfo => + ForeachEntityInDirectory(_watchedDirectory, (filePath, currentWriteTime) => { - var fullFilePath = fileInfo.FullName; - - if (!_knownEntities.ContainsKey(fullFilePath)) + if (!_currentSnapshot.TryGetValue(filePath, out var snapshotWriteTime)) { - // New file or directory - RecordChange(fileInfo, ChangeKind.Add); + _changesBuilder.TryAdd(filePath, ChangeKind.Add); } - else + else if (snapshotWriteTime != currentWriteTime) { - var fileMeta = _knownEntities[fullFilePath]; - - try - { - if (!fileMeta.FileInfo.Attributes.HasFlag(FileAttributes.Directory) && - fileMeta.FileInfo.LastWriteTime != fileInfo.LastWriteTime) - { - // File changed - RecordChange(fileInfo, ChangeKind.Update); - } - - _knownEntities[fullFilePath] = new FileMeta(fileMeta.FileInfo, foundAgain: true); - } - catch (FileNotFoundException) - { - _knownEntities[fullFilePath] = new FileMeta(fileMeta.FileInfo, foundAgain: false); - } + _changesBuilder.TryAdd(filePath, ChangeKind.Update); } - _tempDictionary.Add(fileInfo.FullName, new FileMeta(fileInfo, foundAgain: false)); + _snapshotBuilder.Add(filePath, currentWriteTime); }); - foreach (var file in _knownEntities) + foreach (var (filePath, _) in _currentSnapshot) { - if (!file.Value.FoundAgain) + if (!_snapshotBuilder.ContainsKey(filePath)) { - // File or directory deleted - RecordChange(file.Value.FileInfo, ChangeKind.Delete); + _changesBuilder.TryAdd(filePath, ChangeKind.Delete); } } - NotifyChanges(); + NotifyChanges(_changesBuilder); // Swap the two dictionaries - (_tempDictionary, _knownEntities) = (_knownEntities, _tempDictionary); - _tempDictionary.Clear(); - } + (_snapshotBuilder, _currentSnapshot) = (_currentSnapshot, _snapshotBuilder); - private void RecordChange(FileSystemInfo fileInfo, ChangeKind kind) - { - if (_changes.ContainsKey(fileInfo.FullName) || - fileInfo.FullName.Equals(_watchedDirectory.FullName, StringComparison.Ordinal)) - { - return; - } - - _changes.Add(fileInfo.FullName, kind); - - if (fileInfo is FileInfo { Directory: { } directory }) - { - RecordChange(directory, ChangeKind.Update); - } - else if (fileInfo is DirectoryInfo { Parent: { } parent }) - { - RecordChange(parent, ChangeKind.Update); - } + _changesBuilder.Clear(); + _snapshotBuilder.Clear(); } - private static void ForeachEntityInDirectory(DirectoryInfo dirInfo, Action fileAction) + private void ForeachEntityInDirectory(DirectoryInfo dirInfo, Action fileAction) { if (!dirInfo.Exists) { @@ -184,7 +149,7 @@ private static void ForeachEntityInDirectory(DirectoryInfo dirInfo, Action entities; try { - entities = dirInfo.EnumerateFileSystemInfos("*.*"); + entities = dirInfo.EnumerateFileSystemInfos("*.*", SearchOption.TopDirectoryOnly); } // If the directory is deleted after the exists check this will throw and could crash the process catch (DirectoryNotFoundException) @@ -194,18 +159,35 @@ private static void ForeachEntityInDirectory(DirectoryInfo dirInfo, Action changes) { - foreach (var (path, kind) in _changes) + foreach (var (path, kind) in changes) { if (_disposed || !_raiseEvents) { @@ -215,11 +197,5 @@ private void NotifyChanges() OnFileChange?.Invoke(this, new ChangedPath(path, kind)); } } - - private readonly struct FileMeta(FileSystemInfo fileInfo, bool foundAgain) - { - public readonly FileSystemInfo FileInfo = fileInfo; - public readonly bool FoundAgain = foundAgain; - } } } diff --git a/src/BuiltInTools/dotnet-watch/Utilities/PathUtilities.cs b/src/BuiltInTools/dotnet-watch/Utilities/PathUtilities.cs index 9d170b074cfa..b3a1349ae8ca 100644 --- a/src/BuiltInTools/dotnet-watch/Utilities/PathUtilities.cs +++ b/src/BuiltInTools/dotnet-watch/Utilities/PathUtilities.cs @@ -6,9 +6,21 @@ namespace Microsoft.DotNet.Watch; internal static class PathUtilities { public static readonly IEqualityComparer OSSpecificPathComparer = Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + public static readonly StringComparison OSSpecificPathComparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + public static string EnsureTrailingSlash(string path) + => (path is [.., var last] && last != Path.DirectorySeparatorChar) ? path + Path.DirectorySeparatorChar : path; + + public static string NormalizeDirectorySeparators(string path) + => path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); public static bool ContainsPath(IReadOnlySet directories, string fullPath) { + if (directories.Count == 0) + { + return false; + } + fullPath = Path.TrimEndingDirectorySeparator(fullPath); while (true) diff --git a/test/dotnet-watch.Tests/FileWatcherTests.cs b/test/dotnet-watch.Tests/FileWatcherTests.cs index f52bc5e0d49d..70a89dd256d5 100644 --- a/test/dotnet-watch.Tests/FileWatcherTests.cs +++ b/test/dotnet-watch.Tests/FileWatcherTests.cs @@ -3,6 +3,8 @@ #nullable disable +using System.Diagnostics; + namespace Microsoft.DotNet.Watch.UnitTests { public class FileWatcherTests(ITestOutputHelper output) @@ -15,15 +17,16 @@ private async Task TestOperation( string dir, ChangedPath[] expectedChanges, bool usePolling, + bool watchSubdirectories, Action operation) { - using var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling); + using var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling, includeSubdirectories: watchSubdirectories); if (watcher is EventBasedDirectoryWatcher dotnetWatcher) { dotnetWatcher.Logger = m => output.WriteLine(m); } - var changedEv = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var operationCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var filesChanged = new HashSet(); EventHandler handler = null; @@ -42,7 +45,7 @@ private async Task TestOperation( { watcher.EnableRaisingEvents = false; watcher.OnFileChange -= handler; - changedEv.TrySetResult(); + operationCompletionSource.TrySetResult(); } }; @@ -59,81 +62,96 @@ private async Task TestOperation( operation(); - await changedEv.Task.TimeoutAfter(DefaultTimeout); - AssertEx.SequenceEqual(expectedChanges, filesChanged.Order(Comparer.Create((x, y) => (x.Path, x.Kind).CompareTo((y.Path, y.Kind))))); + var task = operationCompletionSource.Task; + await (Debugger.IsAttached ? task : task.TimeoutAfter(DefaultTimeout)); + + AssertEx.SequenceEqual(expectedChanges, filesChanged.OrderBy(x => x.Path)); } - [PlatformSpecificTheory(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/49307 - [InlineData(true)] - [InlineData(false)] + [Theory] + [CombinatorialData] public async Task NewFile(bool usePolling) { var dir = _testAssetManager.CreateTestDirectory(identifier: usePolling.ToString()).Path; - var testFileFullPath = Path.Combine(dir, "foo"); + var file = Path.Combine(dir, "file"); await TestOperation( dir, - expectedChanges: !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !usePolling + expectedChanges: !RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || usePolling ? [ - new(testFileFullPath, ChangeKind.Update), - new(testFileFullPath, ChangeKind.Add), + new(file, ChangeKind.Add), ] : [ - new(testFileFullPath, ChangeKind.Add), + new(file, ChangeKind.Update), + new(file, ChangeKind.Add), ], usePolling, - () => File.WriteAllText(testFileFullPath, string.Empty)); + watchSubdirectories: true, + () => File.WriteAllText(file, string.Empty)); } - [PlatformSpecificTheory(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/49307 - [InlineData(true)] - [InlineData(false)] - public async Task NewFileInNewDirectory(bool usePolling) + [Theory] + [CombinatorialData] + public async Task NewFileInNewDirectory(bool usePolling, bool nested) { + if (!usePolling && !(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))) + { + // Skip test on Unix: + // https://github.com/dotnet/runtime/issues/116351 + return; + } + var dir = _testAssetManager.CreateTestDirectory(identifier: usePolling.ToString()).Path; - var newDir = Path.Combine(dir, "Dir"); - var newFile = Path.Combine(newDir, "foo"); + var dir1 = Path.Combine(dir, "dir1"); + var dir2 = nested ? Path.Combine(dir1, "dir2") : dir1; + var fileInSubdir = Path.Combine(dir2, "file_in_subdir"); await TestOperation( dir, - expectedChanges: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !usePolling + expectedChanges: !RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || usePolling ? [ - new(newDir, ChangeKind.Add), - new(newFile, ChangeKind.Update), - new(newFile, ChangeKind.Add), + new(fileInSubdir, ChangeKind.Add), ] : [ - new(newDir, ChangeKind.Add), + new(fileInSubdir, ChangeKind.Update), + new(fileInSubdir, ChangeKind.Add), ], usePolling, + watchSubdirectories: true, () => { - Directory.CreateDirectory(newDir); - File.WriteAllText(newFile, string.Empty); + Directory.CreateDirectory(dir1); + + if (nested) + { + Directory.CreateDirectory(dir2); + } + + File.WriteAllText(fileInSubdir, string.Empty); }); } - [PlatformSpecificTheory(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/49307 - [InlineData(true)] - [InlineData(false)] + [Theory] + [CombinatorialData] public async Task ChangeFile(bool usePolling) { var dir = _testAssetManager.CreateTestDirectory(identifier: usePolling.ToString()).Path; - var testFileFullPath = Path.Combine(dir, "foo"); - File.WriteAllText(testFileFullPath, string.Empty); + var file = Path.Combine(dir, "file"); + File.WriteAllText(file, string.Empty); await TestOperation( dir, - expectedChanges: [new(testFileFullPath, ChangeKind.Update)], + expectedChanges: [new(file, ChangeKind.Update)], usePolling, - () => File.WriteAllText(testFileFullPath, string.Empty)); + watchSubdirectories: true, + () => File.WriteAllText(file, string.Empty)); } [PlatformSpecificTheory(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/49307 @@ -141,8 +159,8 @@ await TestOperation( public async Task MoveFile(bool usePolling) { var dir = _testAssetManager.CreateTestDirectory(identifier: usePolling.ToString()).Path; - var srcFile = Path.Combine(dir, "foo"); - var dstFile = Path.Combine(dir, "foo2"); + var srcFile = Path.Combine(dir, "file"); + var dstFile = Path.Combine(dir, "file2"); File.WriteAllText(srcFile, string.Empty); @@ -162,40 +180,69 @@ await TestOperation( new(dstFile, ChangeKind.Add), ], usePolling, + watchSubdirectories: true, () => File.Move(srcFile, dstFile)); - } - [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307") - public async Task FileInSubdirectory() + [Theory] + [CombinatorialData] + public async Task FileInSubdirectory(bool usePolling, bool watchSubdirectories) { - var dir = _testAssetManager.CreateTestDirectory().Path; + var dir = _testAssetManager.CreateTestDirectory(identifier: $"{usePolling}{watchSubdirectories}").Path; var subdir = Path.Combine(dir, "subdir"); Directory.CreateDirectory(subdir); - var testFileFullPath = Path.Combine(subdir, "foo"); - File.WriteAllText(testFileFullPath, string.Empty); + var fileInDir = Path.Combine(dir, "file_in_dir"); + File.WriteAllText(fileInDir, string.Empty); + + var fileInSubdir = Path.Combine(subdir, "file_in_subdir"); + File.WriteAllText(fileInSubdir, string.Empty); + + ChangedPath[] expectedChanges; + + if (watchSubdirectories) + { + expectedChanges = !RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || usePolling ? + [ + new(fileInDir, ChangeKind.Update), + new(fileInSubdir, ChangeKind.Update) + ] + : + [ + new(fileInDir, ChangeKind.Update), + new(fileInDir, ChangeKind.Add), + new(fileInSubdir, ChangeKind.Update), + new(fileInSubdir, ChangeKind.Add), + ]; + } + else + { + expectedChanges = + [ + new(fileInDir, ChangeKind.Update), + ]; + } await TestOperation( dir, - expectedChanges: - [ - new(subdir, ChangeKind.Update), - new(testFileFullPath, ChangeKind.Update) - ], - usePolling: true, - () => File.WriteAllText(testFileFullPath, string.Empty)); + expectedChanges, + usePolling, + watchSubdirectories, + () => + { + File.WriteAllText(fileInSubdir, string.Empty); + File.WriteAllText(fileInDir, string.Empty); + }); } - [PlatformSpecificTheory(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/49307 - [InlineData(true)] - [InlineData(false)] + [Theory] + [CombinatorialData] public async Task NoNotificationIfDisabled(bool usePolling) { var dir = _testAssetManager.CreateTestDirectory(identifier: usePolling.ToString()).Path; - using var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling); + using var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling, includeSubdirectories: true); var changedEv = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); watcher.OnFileChange += (_, f) => changedEv.TrySetResult(0); @@ -217,20 +264,19 @@ public async Task NoNotificationIfDisabled(bool usePolling) await Assert.ThrowsAsync(() => changedEv.Task.TimeoutAfter(NegativeTimeout)); } - [PlatformSpecificTheory(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/49307 - [InlineData(true)] - [InlineData(false)] + [Theory] + [CombinatorialData] public async Task DisposedNoEvents(bool usePolling) { var dir = _testAssetManager.CreateTestDirectory(identifier: usePolling.ToString()).Path; var changedEv = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using (var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling)) + using (var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling, includeSubdirectories: true)) { watcher.OnFileChange += (_, f) => changedEv.TrySetResult(); watcher.EnableRaisingEvents = true; } - var testFileFullPath = Path.Combine(dir, "foo"); + var file = Path.Combine(dir, "file"); if (usePolling) { @@ -239,47 +285,39 @@ public async Task DisposedNoEvents(bool usePolling) // watcher will not detect the change await Task.Delay(1000); } - File.WriteAllText(testFileFullPath, string.Empty); + File.WriteAllText(file, string.Empty); await Assert.ThrowsAsync(() => changedEv.Task.TimeoutAfter(NegativeTimeout)); } - [PlatformSpecificTheory(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/49307 - [InlineData(true)] - [InlineData(false)] + [Theory] + [CombinatorialData] public async Task MultipleFiles(bool usePolling) { var dir = _testAssetManager.CreateTestDirectory(identifier: usePolling.ToString()).Path; - File.WriteAllText(Path.Combine(dir, "foo1"), string.Empty); - File.WriteAllText(Path.Combine(dir, "foo2"), string.Empty); - File.WriteAllText(Path.Combine(dir, "foo3"), string.Empty); - File.WriteAllText(Path.Combine(dir, "foo4"), string.Empty); - - // On Unix the native file watcher may surface events from - // the recent past. Delay to avoid those. - // On Unix the file write time is in 1s increments; - // if we don't wait, there's a chance that the polling - // watcher will not detect the change - await Task.Delay(1250); + File.WriteAllText(Path.Combine(dir, "file1"), string.Empty); + File.WriteAllText(Path.Combine(dir, "file2"), string.Empty); + File.WriteAllText(Path.Combine(dir, "file3"), string.Empty); + File.WriteAllText(Path.Combine(dir, "file4"), string.Empty); - var testFileFullPath = Path.Combine(dir, "foo3"); + var file3 = Path.Combine(dir, "file3"); await TestOperation( dir, - expectedChanges: [new(testFileFullPath, ChangeKind.Update)], - usePolling: true, - () => File.WriteAllText(testFileFullPath, string.Empty)); + expectedChanges: [new(file3, ChangeKind.Update)], + usePolling, + watchSubdirectories: true, + () => File.WriteAllText(file3, string.Empty)); } - [PlatformSpecificTheory(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/49307 - [InlineData(true)] - [InlineData(false)] + [Theory] + [CombinatorialData] public async Task MultipleTriggers(bool usePolling) { var dir = _testAssetManager.CreateTestDirectory(identifier: usePolling.ToString()).Path; - using var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling); + using var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling, includeSubdirectories: true); watcher.EnableRaisingEvents = true; @@ -332,9 +370,8 @@ private async Task AssertFileChangeRaisesEvent(string directory, IDirectoryWatch } } - [PlatformSpecificTheory(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/49307 - [InlineData(true)] - [InlineData(false)] + [Theory] + [CombinatorialData] public async Task DeleteSubfolder(bool usePolling) { var dir = _testAssetManager.CreateTestDirectory(usePolling.ToString()).Path; @@ -352,17 +389,8 @@ public async Task DeleteSubfolder(bool usePolling) await TestOperation( dir, - expectedChanges: usePolling ? - [ - new(subdir, ChangeKind.Delete), - new(f1, ChangeKind.Delete), - new(f2, ChangeKind.Delete), - new(f3, ChangeKind.Delete), - ] - : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? + expectedChanges: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !usePolling ? [ - new(subdir, ChangeKind.Add), - new(subdir, ChangeKind.Delete), new(f1, ChangeKind.Update), new(f1, ChangeKind.Add), new(f1, ChangeKind.Delete), @@ -373,22 +401,14 @@ await TestOperation( new(f3, ChangeKind.Add), new(f3, ChangeKind.Delete), ] - : RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? - [ - new(subdir, ChangeKind.Update), - new(subdir, ChangeKind.Delete), - new(f1, ChangeKind.Delete), - new(f2, ChangeKind.Delete), - new(f3, ChangeKind.Delete), - ] : [ - new(subdir, ChangeKind.Delete), new(f1, ChangeKind.Delete), new(f2, ChangeKind.Delete), new(f3, ChangeKind.Delete), ], usePolling, + watchSubdirectories: true, () => Directory.Delete(subdir, recursive: true)); } }