Skip to content
Draft
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 @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Comment thread
svick marked this conversation as resolved.
}

IChangeToken changeToken = tokenInfo.ChangeToken;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, ChangeTokenInfo> tokens)
void CancelAll(ConcurrentDictionary<string, ChangeTokenInfo> tokens, Func<string, bool> requiresSubdirectories)
{
foreach (KeyValuePair<string, ChangeTokenInfo> entry in tokens)
{
if (tokens.TryRemove(entry.Key, out ChangeTokenInfo matchInfo))
{
if (requiresSubdirectories(entry.Key))
{
Interlocked.Decrement(ref _subdirectoryRequiringTokenCount);
}

CancelToken(matchInfo);
}
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Comment thread
svick marked this conversation as resolved.
public void IncludeSubdirectories_OnlyEnabledWhenAPatternRequiresIt(string[] patterns, bool expectedIncludeSubdirectories)
Comment thread
svick marked this conversation as resolved.
{
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()
Expand Down
Loading