diff --git a/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs b/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs index 477bd8ca2045c8..344369cf53c676 100644 --- a/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs +++ b/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs @@ -34,6 +34,14 @@ public class PhysicalFilesWatcher : IDisposable private readonly object _fileWatcherLock = new(); private readonly string _root; private readonly ExclusionFilters _filters; + // True when the FileSystemWatcher watches a strict ancestor of _root (rather than _root + // itself). In that case IncludeSubdirectories must always be true so that events occurring + // inside _root (which is below the FSW's watched path) are observed. + private readonly bool _fileWatcherIsAboveRoot; + // Number of currently registered tokens whose pattern requires watching subdirectories. + // Maintained as tokens are added and removed so we don't iterate the lookups when + // re-evaluating IncludeSubdirectories. + private int _subdirectoryRequiringTokenCount; // A single non-recursive watcher used when _root does not exist. // Watches for _root to be created, then enables the main FileSystemWatcher. @@ -111,10 +119,15 @@ public PhysicalFilesWatcher( { throw new ArgumentException(SR.Format(SR.FileSystemWatcherPathError, watcherFullPath, _root), nameof(fileSystemWatcher)); } + + // If the FSW watches an ancestor of _root, every event of interest occurs + // in a subdirectory from the FSW's perspective, so subdirectory watching + // is required to observe any of them. + _fileWatcherIsAboveRoot = !watcherFullPath.Equals(_root, StringComparison.OrdinalIgnoreCase) && + _root.StartsWith(watcherFullPath, StringComparison.OrdinalIgnoreCase); } _fileWatcher = fileSystemWatcher; - _fileWatcher.IncludeSubdirectories = true; _fileWatcher.Created += OnChanged; _fileWatcher.Changed += OnChanged; _fileWatcher.Renamed += OnRenamed; @@ -189,8 +202,16 @@ internal IChangeToken GetOrAddFilePathChangeToken(string filePath) { var cancellationTokenSource = new CancellationTokenSource(); var cancellationChangeToken = new CancellationChangeToken(cancellationTokenSource.Token); - tokenInfo = new ChangeTokenInfo(cancellationTokenSource, cancellationChangeToken); - tokenInfo = _filePathTokenLookup.GetOrAdd(filePath, tokenInfo); + var newTokenInfo = new ChangeTokenInfo(cancellationTokenSource, cancellationChangeToken); + tokenInfo = _filePathTokenLookup.GetOrAdd(filePath, newTokenInfo); + + // GetOrAdd may not have actually added our entry if another thread won the race. + // Compare by reference to detect whether our entry was the one stored. + if (ReferenceEquals(tokenInfo.TokenSource, cancellationTokenSource) && + FilePathRequiresSubdirectories(filePath)) + { + Interlocked.Increment(ref _subdirectoryRequiringTokenCount); + } } IChangeToken changeToken = tokenInfo.ChangeToken; @@ -227,8 +248,14 @@ internal IChangeToken GetOrAddWildcardChangeToken(string pattern) var cancellationChangeToken = new CancellationChangeToken(cancellationTokenSource.Token); var matcher = new Matcher(StringComparison.OrdinalIgnoreCase); matcher.AddInclude(pattern); - tokenInfo = new ChangeTokenInfo(cancellationTokenSource, cancellationChangeToken, matcher); - tokenInfo = _wildcardTokenLookup.GetOrAdd(pattern, tokenInfo); + var newTokenInfo = new ChangeTokenInfo(cancellationTokenSource, cancellationChangeToken, matcher); + tokenInfo = _wildcardTokenLookup.GetOrAdd(pattern, newTokenInfo); + + if (ReferenceEquals(tokenInfo.TokenSource, cancellationTokenSource) && + WildcardRequiresSubdirectories(pattern)) + { + Interlocked.Increment(ref _subdirectoryRequiringTokenCount); + } } IChangeToken changeToken = tokenInfo.ChangeToken; @@ -347,17 +374,22 @@ private void OnChanged(object sender, FileSystemEventArgs e) private void OnError(object sender, ErrorEventArgs e) { // Notify all cache entries on error. - CancelAll(_filePathTokenLookup); - CancelAll(_wildcardTokenLookup); + CancelAll(_filePathTokenLookup, FilePathRequiresSubdirectories); + CancelAll(_wildcardTokenLookup, WildcardRequiresSubdirectories); TryDisableFileSystemWatcher(); - static void CancelAll(ConcurrentDictionary tokens) + void CancelAll(ConcurrentDictionary tokens, Func requiresSubdirectories) { foreach (KeyValuePair entry in tokens) { if (tokens.TryRemove(entry.Key, out ChangeTokenInfo matchInfo)) { + if (requiresSubdirectories(entry.Key)) + { + Interlocked.Decrement(ref _subdirectoryRequiringTokenCount); + } + CancelToken(matchInfo); } } @@ -418,6 +450,11 @@ private void ReportChangeForMatchedEntries(string path) bool matched = false; if (_filePathTokenLookup.TryRemove(path, out ChangeTokenInfo matchInfo)) { + if (FilePathRequiresSubdirectories(path)) + { + Interlocked.Decrement(ref _subdirectoryRequiringTokenCount); + } + CancelToken(matchInfo); matched = true; } @@ -428,6 +465,11 @@ private void ReportChangeForMatchedEntries(string path) if (matchResult.HasMatches && _wildcardTokenLookup.TryRemove(wildCardEntry.Key, out matchInfo)) { + if (WildcardRequiresSubdirectories(wildCardEntry.Key)) + { + Interlocked.Decrement(ref _subdirectoryRequiringTokenCount); + } + CancelToken(matchInfo); matched = true; } @@ -491,6 +533,14 @@ private void TryDisableFileSystemWatcher() // Perf: Turn off the file monitoring if no files or directories to monitor. _fileWatcher.EnableRaisingEvents = false; } + else if (_fileWatcher.IncludeSubdirectories && + !_fileWatcherIsAboveRoot && + Volatile.Read(ref _subdirectoryRequiringTokenCount) == 0) + { + // Perf: Some tokens were removed and none of the remaining ones require + // subdirectory watching, so we can stop recursing. + _fileWatcher.IncludeSubdirectories = false; + } } } @@ -529,6 +579,17 @@ private void TryEnableFileSystemWatcher() { bool rootExists = Directory.Exists(_root); + // Only enable recursive subdirectory watching when at least one registered + // pattern actually references a subdirectory. This avoids creating an inotify + // watch on every descendant directory on Linux when only root-level files + // (e.g. appsettings.json) are being monitored. + bool needsSubdirectories = _fileWatcherIsAboveRoot || + Volatile.Read(ref _subdirectoryRequiringTokenCount) > 0; + if (_fileWatcher.IncludeSubdirectories != needsSubdirectories) + { + _fileWatcher.IncludeSubdirectories = needsSubdirectories; + } + // On some platforms (e.g., Linux), FileSystemWatcher currently does not // invoke OnError when the watched directory is deleted, so we don't disable // the FSW and start root watcher at that point. @@ -655,6 +716,18 @@ private void EnsureRootCreationWatcher() } } + // Patterns are normalized to use forward slashes. A file path token references a single + // file, so it requires subdirectory watching only when the path is in a subdirectory + // (i.e. contains '/'). + private static bool FilePathRequiresSubdirectories(string normalizedFilePath) => + normalizedFilePath.Contains('/'); + + // A wildcard pattern requires subdirectory watching when it explicitly references a + // subdirectory (contains '/') or uses the recursive globbing wildcard '**'. A simple + // wildcard like '*' or '*.json' only matches entries directly in the root directory. + private static bool WildcardRequiresSubdirectories(string normalizedPattern) => + normalizedPattern.Contains('/') || normalizedPattern.Contains("**"); + private static string NormalizePath(string filter) => filter.Replace('\\', '/'); private static bool IsDirectoryPath(string path) diff --git a/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs b/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs index 7bee9f6a45f3a9..4581188c71ab44 100644 --- a/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs +++ b/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs @@ -69,6 +69,140 @@ private static async Task WhenChanged(IChangeToken token, bool withTimeout = tru } } + [Theory] + [InlineData(new[] { "appsettings.json" }, false)] + [InlineData(new[] { "appsettings.json", "appsettings.Production.json" }, false)] + [InlineData(new[] { "*.json" }, false)] + [InlineData(new[] { "*" }, false)] + [InlineData(new[] { "sub/file.json" }, true)] + [InlineData(new[] { "sub/*.json" }, true)] + [InlineData(new[] { "**/appsettings.json" }, true)] + [InlineData(new[] { "**" }, true)] + [InlineData(new[] { "sub/" }, true)] + [InlineData(new[] { "appsettings.json", "sub/file.json" }, true)] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public void IncludeSubdirectories_OnlyEnabledWhenAPatternRequiresIt(string[] patterns, bool expectedIncludeSubdirectories) + { + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + foreach (string pattern in patterns) + { + physicalFilesWatcher.CreateFileChangeToken(pattern); + } + + Assert.Equal(expectedIncludeSubdirectories, fileSystemWatcher.IncludeSubdirectories); + Assert.True(fileSystemWatcher.EnableRaisingEvents); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public void IncludeSubdirectories_UpgradedWhenSubdirectoryPatternAddedLater() + { + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + Assert.False(fileSystemWatcher.IncludeSubdirectories); + + physicalFilesWatcher.CreateFileChangeToken("sub/file.json"); + Assert.True(fileSystemWatcher.IncludeSubdirectories); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public void IncludeSubdirectories_DowngradedWhenSubdirectoryPatternRemoved() + { + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + physicalFilesWatcher.CreateFileChangeToken("sub/file.json"); + Assert.True(fileSystemWatcher.IncludeSubdirectories); + + // Fire an event matching the subdirectory token to remove it from the lookup. + // The remaining root-only token does not require subdirectory watching, IncludeSubdirectories should be downgraded. + fileSystemWatcher.CallOnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, root.Path, "sub/file.json")); + Assert.False(fileSystemWatcher.IncludeSubdirectories); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public void IncludeSubdirectories_NotDowngradedWhileSubdirectoryPatternRemains() + { + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + physicalFilesWatcher.CreateFileChangeToken("sub/file.json"); + physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + Assert.True(fileSystemWatcher.IncludeSubdirectories); + + // Remove only the root-level token; the subdirectory token must keep subdirectories enabled. + fileSystemWatcher.CallOnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, root.Path, "appsettings.json")); + Assert.True(fileSystemWatcher.IncludeSubdirectories); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public void IncludeSubdirectories_NotDowngradedWhenWildcardSubdirectoryPatternRemains() + { + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + physicalFilesWatcher.CreateFileChangeToken("sub/*.json"); + physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + Assert.True(fileSystemWatcher.IncludeSubdirectories); + + // Remove the root-level token; the recursive wildcard token must keep subdirectories enabled. + fileSystemWatcher.CallOnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, root.Path, "appsettings.json")); + Assert.True(fileSystemWatcher.IncludeSubdirectories); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public void IncludeSubdirectories_AlwaysTrueWhenWatcherIsAboveRoot() + { + using var tempDir = new TempDirectory(GetTestFilePath()); + string rootDir = Path.Combine(tempDir.Path, "rootDir"); + Directory.CreateDirectory(rootDir); + + using var fsw = new FileSystemWatcher(tempDir.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(rootDir, fsw, pollForChanges: false); + + // Even with only a root-only pattern, IncludeSubdirectories must be true because + // the FSW watches an ancestor of _root. + physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + Assert.True(fsw.IncludeSubdirectories); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task IncludeSubdirectories_StarPatternIsNotRecursive_DoesNotMatchSubdirectoryFile() + { + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + IChangeToken token = physicalFilesWatcher.CreateFileChangeToken("*.json"); + Assert.False(fileSystemWatcher.IncludeSubdirectories); + + Task changed = WhenChanged(token); + + // A '*' pattern should not match files in subdirectories. + fileSystemWatcher.CallOnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, root.Path, "sub/foo.json")); + await Task.Delay(WaitTimeForTokenToFire); + Assert.False(changed.IsCompleted, "Token must not fire for an event in a subdirectory."); + + // Sanity check: a root-level event does fire the token. + fileSystemWatcher.CallOnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, root.Path, "foo.json")); + await changed; + } + [Fact] [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] public void CreateFileChangeToken_DoesNotAllowPathsAboveRoot()