Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Comment thread
svick marked this conversation as resolved.
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);
Expand All @@ -396,6 +437,34 @@ void CancelAll(ConcurrentDictionary<string, ChangeTokenInfo> tokens, Func<string
}
}

// Two errors are considered the same when they have the same type and OS error code. That's a
// strong signal of a persistent condition (for example the same failure for an unwatchable
// volume), while genuinely distinct failures keep being reported. Win32Exception carries the
// code in NativeErrorCode (its HResult is a constant); other exceptions, such as the IOExceptions
// built from errno on Unix, carry it in HResult.
private static bool IsSameError(Exception a, Exception b)
{
if (a.GetType() != b.GetType())
{
return false;
}

if (a is Win32Exception win32A && b is Win32Exception win32B)
{
return win32A.NativeErrorCode == win32B.NativeErrorCode;
}

return a.HResult == b.HResult;
}

private void ClearLastError()
{
lock (_errorLock)
{
_lastError = null;
}
}

[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
[UnsupportedOSPlatform("ios")]
Expand All @@ -421,6 +490,13 @@ private void OnFileSystemEntryChange(string fullPath)
return;
}

// A relevant change was delivered, which proves the watcher is working, so forget any
// remembered error; a later identical error then starts over rather than being treated
// as a persistent recurrence. This should run only for changes inside _root that aren't
// excluded, so unrelated events don't reset the detection while an unwatchable-root
// error loop is in progress.
ClearLastError();

string relativePath = fullPath.Substring(_root.Length);
ReportChangeForMatchedEntries(relativePath);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading;
Expand Down Expand Up @@ -781,6 +782,155 @@ public async Task Watch_DoesNotFireForSiblingDirectoryWithSharedPrefix()
await changed;
}

[Theory]
[InlineData(true)] // Win32Exception -> 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<Exception> 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; }
Expand Down
Loading