diff --git a/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs b/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs index f74d2f3f37760f..ad723228b44278 100644 --- a/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs +++ b/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Runtime.Versioning; @@ -49,6 +50,13 @@ public class PhysicalFilesWatcher : IDisposable private readonly object _rootCreationWatcherLock = new(); private bool _rootWasUnavailable; + // The last Error reported by the FileSystemWatcher that could indicate a persistent failure, + // remembered until a change is successfully delivered (see OnFileSystemEntryChange). Guarded by + // _errorLock. Used to detect the same error occurring again with no progress in between, like a + // file system that can't be watched (for example, a network share or a WSL path accessed from Windows). + private Exception? _lastError; + private readonly object _errorLock = new(); + private Timer? _timer; private bool _timerInitialized; private object _timerLock = new(); @@ -373,6 +381,39 @@ private void OnChanged(object sender, FileSystemEventArgs e) [SupportedOSPlatform("maccatalyst")] private void OnError(object sender, ErrorEventArgs e) { + Exception? error = e.GetException(); + + // An InternalBufferOverflowException means the watcher is still functioning but dropped + // events, so consumers must rescan; a DirectoryNotFoundException means the watched directory + // was deleted or moved, which is a real change the root-creation watcher recovers from. Both + // must always be reported, and both count as progress that clears any remembered error. + if (error is InternalBufferOverflowException or DirectoryNotFoundException) + { + ClearLastError(); + } + else if (error is not null) + { + lock (_errorLock) + { + if (_lastError is not null && IsSameError(_lastError, error)) + { + // The same error recurred with no change delivered in between, so the file + // system can't be watched. Don't cancel tokens: doing so would likely re-create tokens + // and re-enable the watcher, looping until the stack overflows. The watcher is + // still re-enabled on the next request, so if the condition later clears it can + // resume watching. + return; + } + + _lastError = error; + } + } + else + { + // The Error carried no exception (only reachable through a custom FileSystemWatcher). + // Report it like any other error, but leave the remembered-error state untouched. + } + // Notify all cache entries on error. CancelAll(_filePathTokenLookup, FilePathRequiresSubdirectories); CancelAll(_wildcardTokenLookup, WildcardRequiresSubdirectories); @@ -396,6 +437,34 @@ void CancelAll(ConcurrentDictionary tokens, Func matched on NativeErrorCode + [InlineData(false)] // IOException -> matched on HResult + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_SameErrorRecurs_SecondOccurrenceIsSuppressed(bool win32) + { + // Regression test for https://github.com/dotnet/runtime/issues/121475: + // On a file system that can't be watched (e.g. a WSL path accessed from Windows), enabling + // the FileSystemWatcher keeps raising the same Error, which cancels tokens, which re-creates + // tokens and re-enables the watcher, recursing until the stack overflows. The first + // occurrence of an error is reported, but an identical recurrence (same type and error code) + // with no change delivered in between is suppressed so the loop can't form. + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + // First error is reported: it cancels the token created before it. + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32, code: 5))); + await WhenChanged(first); + + // The same error (same code) recurs: it must NOT cancel the new token. + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32, code: 5))); + await Task.Delay(WaitTimeForTokenToFire); + Assert.False(second.HasChanged, "A repeated identical error must not cancel tokens."); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_DifferentErrorCode_IsReported(bool win32) + { + // Distinct errors (same type, different error code) are not the same persistent failure, so + // each is reported. + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32, code: 5))); + await WhenChanged(first); + + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32, code: 6))); + await WhenChanged(second); + } + + [Theory] + [InlineData(true)] // InternalBufferOverflowException + [InlineData(false)] // DirectoryNotFoundException + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_RecoverableError_IsAlwaysReported(bool bufferOverflow) + { + // InternalBufferOverflowException (events were dropped, rescan needed) and + // DirectoryNotFoundException (the watched directory was deleted/moved) mean the watcher is + // still functioning or a real change happened, so every occurrence must be reported even when + // it repeats identically. + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + Func createError = bufferOverflow + ? () => new InternalBufferOverflowException() + : () => new DirectoryNotFoundException(); + + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(createError())); + await WhenChanged(first); + + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(createError())); + await WhenChanged(second); + } + + private static Exception MakeError(bool win32, int code) + => win32 ? new Win32Exception(code) : new IOException("watcher error", code); + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_SameError_AfterDeliveredChange_IsReportedAgain() + { + // A change delivered between two identical errors proves the watcher works, so the second + // error starts over and is reported rather than suppressed as a persistent recurrence. + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32: false, code: 5))); + await WhenChanged(first); + + // A delivered change resets the remembered error. + fileSystemWatcher.CallOnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, root.Path, "unrelated.txt")); + + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32: false, code: 5))); + await WhenChanged(second); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_ChangeOutsideRoot_DoesNotResetDetection() + { + // When the watcher watches an ancestor, it can deliver events for + // siblings outside _root. Such events must not reset the persistent-error detection, otherwise + // unrelated activity could prevent suppression from engaging during an unwatchable-root loop. + using var tempDir = new TempDirectory(GetTestFilePath()); + string rootDir = Path.Combine(tempDir.Path, "rootDir"); + Directory.CreateDirectory(rootDir); + + // The FSW watches the ancestor (tempDir), so it can raise events outside rootDir. + using var fileSystemWatcher = new MockFileSystemWatcher(tempDir.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(rootDir, fileSystemWatcher, pollForChanges: false); + + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32: false, code: 5))); + await WhenChanged(first); + + // A change to a sibling outside rootDir must NOT reset the remembered error. + fileSystemWatcher.CallOnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, tempDir.Path, "outside.txt")); + + // The same error recurs: it must still be suppressed. + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32: false, code: 5))); + await Task.Delay(WaitTimeForTokenToFire); + Assert.False(second.HasChanged, "An out-of-root change must not reset persistent-error detection."); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_NullException_IsAlwaysReported() + { + // An Error with no exception carries no identity to de-duplicate, so every occurrence is + // reported rather than suppressed. + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(null!)); + await WhenChanged(first); + + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(null!)); + await WhenChanged(second); + } + private class TestPollingChangeToken : IPollingChangeToken { public int Id { get; set; }