|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO;
using System.Runtime.Versioning;
using System.Security;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.FileProviders.Physical.Internal;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.Internal;
using Microsoft.Extensions.Primitives;
namespace Microsoft.Extensions.FileProviders.Physical
{
/// <summary>
/// Watches a physical file system for changes and triggers events on
/// <see cref="IChangeToken" /> when files are created, change, renamed, or deleted.
/// </summary>
public class PhysicalFilesWatcher : IDisposable
{
private static readonly Action<object?> _cancelTokenSource = state => ((CancellationTokenSource?)state)!.Cancel();
internal static TimeSpan DefaultPollingInterval = TimeSpan.FromSeconds(4);
private readonly ConcurrentDictionary<string, ChangeTokenInfo> _filePathTokenLookup = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, ChangeTokenInfo> _wildcardTokenLookup = new(StringComparer.OrdinalIgnoreCase);
private readonly FileSystemWatcher? _fileWatcher;
private readonly object _fileWatcherLock = new();
private readonly string _root;
private readonly ExclusionFilters _filters;
private Timer? _timer;
private bool _timerInitialized;
private object _timerLock = new();
private readonly Func<Timer> _timerFactory;
private bool _disposed;
/// <summary>
/// Initializes an instance of <see cref="PhysicalFilesWatcher" /> that watches files in <paramref name="root" />.
/// Wraps an instance of <see cref="System.IO.FileSystemWatcher" />.
/// </summary>
/// <param name="root">Root directory for the watcher.</param>
/// <param name="fileSystemWatcher">The wrapped watcher that's watching <paramref name="root" />.</param>
/// <param name="pollForChanges">
/// <see langword="true"/> for the poller to use polling to trigger instances of
/// <see cref="IChangeToken" /> created by <see cref="CreateFileChangeToken(string)" />.
/// </param>
public PhysicalFilesWatcher(
string root,
FileSystemWatcher? fileSystemWatcher,
bool pollForChanges)
: this(root, fileSystemWatcher, pollForChanges, ExclusionFilters.Sensitive)
{
}
/// <summary>
/// Initializes an instance of <see cref="PhysicalFilesWatcher" /> that watches files in <paramref name="root" />.
/// Wraps an instance of <see cref="System.IO.FileSystemWatcher" />.
/// </summary>
/// <param name="root">Root directory for the watcher.</param>
/// <param name="fileSystemWatcher">The wrapped watcher that is watching <paramref name="root" />.</param>
/// <param name="pollForChanges">
/// <see langword="true"/> for the poller to use polling to trigger instances of
/// <see cref="IChangeToken" /> created by <see cref="CreateFileChangeToken(string)" />.
/// </param>
/// <param name="filters">Specifies which files or directories are excluded. Notifications of changes to are not raised to these.</param>
public PhysicalFilesWatcher(
string root,
FileSystemWatcher? fileSystemWatcher,
bool pollForChanges,
ExclusionFilters filters)
{
if (fileSystemWatcher == null && !pollForChanges)
{
throw new ArgumentNullException(nameof(fileSystemWatcher), SR.Error_FileSystemWatcherRequiredWithoutPolling);
}
_root = root;
if (fileSystemWatcher != null)
{
#if NET
if (OperatingSystem.IsBrowser() || OperatingSystem.IsWasi() || (OperatingSystem.IsIOS() && !OperatingSystem.IsMacCatalyst()) || OperatingSystem.IsTvOS())
{
throw new PlatformNotSupportedException(SR.Format(SR.FileSystemWatcher_PlatformNotSupported, typeof(FileSystemWatcher)));
}
#endif
_fileWatcher = fileSystemWatcher;
_fileWatcher.IncludeSubdirectories = true;
_fileWatcher.Created += OnChanged;
_fileWatcher.Changed += OnChanged;
_fileWatcher.Renamed += OnRenamed;
_fileWatcher.Deleted += OnChanged;
_fileWatcher.Error += OnError;
}
PollForChanges = pollForChanges;
_filters = filters;
PollingChangeTokens = new ConcurrentDictionary<IPollingChangeToken, IPollingChangeToken>();
_timerFactory = () => NonCapturingTimer.Create(RaiseChangeEvents, state: PollingChangeTokens, dueTime: TimeSpan.Zero, period: DefaultPollingInterval);
}
internal bool PollForChanges { get; }
internal bool UseActivePolling { get; set; }
internal ConcurrentDictionary<IPollingChangeToken, IPollingChangeToken> PollingChangeTokens { get; }
/// <summary>
/// Creates an instance of <see cref="IChangeToken" /> for all files and directories that match the
/// <paramref name="filter" />.
/// </summary>
/// <param name="filter">A globbing pattern for files and directories to watch.</param>
/// <returns>A change token for all files that match the filter.</returns>
/// <remarks>
/// Globbing patterns are relative to the root directory given in the constructor
/// <see cref="PhysicalFilesWatcher(string, FileSystemWatcher, bool)" />. Globbing patterns
/// are interpreted by <see cref="Matcher" />.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="filter" /> is <see langword="null"/>.</exception>
public IChangeToken CreateFileChangeToken(string filter)
{
ThrowHelper.ThrowIfNull(filter);
filter = NormalizePath(filter);
// Absolute paths and paths traversing above root not permitted.
if (Path.IsPathRooted(filter) || PathUtils.PathNavigatesAboveRoot(filter))
{
return NullChangeToken.Singleton;
}
IChangeToken changeToken = GetOrAddChangeToken(filter);
// We made sure that browser/iOS/tvOS never uses FileSystemWatcher.
#pragma warning disable CA1416 // Validate platform compatibility
TryEnableFileSystemWatcher();
#pragma warning restore CA1416 // Validate platform compatibility
return changeToken;
}
private IChangeToken GetOrAddChangeToken(string pattern)
{
if (UseActivePolling)
{
LazyInitializer.EnsureInitialized(ref _timer, ref _timerInitialized, ref _timerLock, _timerFactory);
}
IChangeToken changeToken;
#if NET
bool isWildCard = pattern.Contains('*');
#else
bool isWildCard = pattern.IndexOf('*') != -1;
#endif
if (isWildCard || IsDirectoryPath(pattern))
{
changeToken = GetOrAddWildcardChangeToken(pattern);
}
else
{
changeToken = GetOrAddFilePathChangeToken(pattern);
}
return changeToken;
}
internal IChangeToken GetOrAddFilePathChangeToken(string filePath)
{
if (!_filePathTokenLookup.TryGetValue(filePath, out ChangeTokenInfo tokenInfo))
{
var cancellationTokenSource = new CancellationTokenSource();
var cancellationChangeToken = new CancellationChangeToken(cancellationTokenSource.Token);
tokenInfo = new ChangeTokenInfo(cancellationTokenSource, cancellationChangeToken);
tokenInfo = _filePathTokenLookup.GetOrAdd(filePath, tokenInfo);
}
IChangeToken changeToken = tokenInfo.ChangeToken;
if (PollForChanges)
{
// The expiry of CancellationChangeToken is controlled by this type and consequently we can cache it.
// PollingFileChangeToken on the other hand manages its own lifetime and consequently we cannot cache it.
var pollingChangeToken = new PollingFileChangeToken(new FileInfo(Path.Combine(_root, filePath)));
if (UseActivePolling)
{
pollingChangeToken.ActiveChangeCallbacks = true;
pollingChangeToken.CancellationTokenSource = new CancellationTokenSource();
PollingChangeTokens.TryAdd(pollingChangeToken, pollingChangeToken);
}
changeToken = new CompositeChangeToken(
new[]
{
changeToken,
pollingChangeToken,
});
}
return changeToken;
}
internal IChangeToken GetOrAddWildcardChangeToken(string pattern)
{
if (!_wildcardTokenLookup.TryGetValue(pattern, out ChangeTokenInfo tokenInfo))
{
var cancellationTokenSource = new CancellationTokenSource();
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);
}
IChangeToken changeToken = tokenInfo.ChangeToken;
if (PollForChanges)
{
// The expiry of CancellationChangeToken is controlled by this type and consequently we can cache it.
// PollingFileChangeToken on the other hand manages its own lifetime and consequently we cannot cache it.
var pollingChangeToken = new PollingWildCardChangeToken(_root, pattern);
if (UseActivePolling)
{
pollingChangeToken.ActiveChangeCallbacks = true;
pollingChangeToken.CancellationTokenSource = new CancellationTokenSource();
PollingChangeTokens.TryAdd(pollingChangeToken, pollingChangeToken);
}
changeToken = new CompositeChangeToken(
new[]
{
changeToken,
pollingChangeToken,
});
}
return changeToken;
}
/// <summary>
/// Disposes the provider. Change tokens may not trigger after the provider is disposed.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Disposes the provider.
/// </summary>
/// <param name="disposing"><c>true</c> is invoked from <see cref="IDisposable.Dispose"/>.</param>
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_fileWatcher?.Dispose();
_timer?.Dispose();
}
_disposed = true;
}
}
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
private void OnRenamed(object sender, RenamedEventArgs e)
{
// For a file name change or a directory's name change notify registered tokens.
OnFileSystemEntryChange(e.OldFullPath);
OnFileSystemEntryChange(e.FullPath);
if (Directory.Exists(e.FullPath))
{
try
{
// If the renamed entity is a directory then notify tokens for every sub item.
foreach (
string newLocation in
Directory.EnumerateFileSystemEntries(e.FullPath, "*", SearchOption.AllDirectories))
{
// Calculated previous path of this moved item.
string oldLocation = Path.Combine(e.OldFullPath, newLocation.Substring(e.FullPath.Length + 1));
OnFileSystemEntryChange(oldLocation);
OnFileSystemEntryChange(newLocation);
}
}
catch (Exception ex) when (
ex is IOException ||
ex is SecurityException ||
ex is DirectoryNotFoundException ||
ex is UnauthorizedAccessException)
{
// Swallow the exception.
}
}
}
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
private void OnChanged(object sender, FileSystemEventArgs e)
{
OnFileSystemEntryChange(e.FullPath);
}
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
private void OnError(object sender, ErrorEventArgs e)
{
// Notify all cache entries on error.
foreach (string path in _filePathTokenLookup.Keys)
{
ReportChangeForMatchedEntries(path);
}
}
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
private void OnFileSystemEntryChange(string fullPath)
{
try
{
var fileSystemInfo = new FileInfo(fullPath);
if (FileSystemInfoHelper.IsExcluded(fileSystemInfo, _filters))
{
return;
}
string relativePath = fullPath.Substring(_root.Length);
ReportChangeForMatchedEntries(relativePath);
}
catch (Exception ex) when (
ex is IOException ||
ex is SecurityException ||
ex is UnauthorizedAccessException)
{
// Swallow the exception.
}
}
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
private void ReportChangeForMatchedEntries(string path)
{
if (string.IsNullOrEmpty(path))
{
// System.IO.FileSystemWatcher may trigger events that are missing the file name,
// which makes it appear as if the root directory is renamed or deleted. Moving the root directory
// of the file watcher is not supported, so this type of event is ignored.
return;
}
path = NormalizePath(path);
bool matched = false;
if (_filePathTokenLookup.TryRemove(path, out ChangeTokenInfo matchInfo))
{
CancelToken(matchInfo);
matched = true;
}
foreach (System.Collections.Generic.KeyValuePair<string, ChangeTokenInfo> wildCardEntry in _wildcardTokenLookup)
{
PatternMatchingResult matchResult = wildCardEntry.Value.Matcher!.Match(path);
if (matchResult.HasMatches &&
_wildcardTokenLookup.TryRemove(wildCardEntry.Key, out matchInfo))
{
CancelToken(matchInfo);
matched = true;
}
}
if (matched)
{
TryDisableFileSystemWatcher();
}
}
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
private void TryDisableFileSystemWatcher()
{
if (_fileWatcher != null)
{
lock (_fileWatcherLock)
{
if (_filePathTokenLookup.IsEmpty &&
_wildcardTokenLookup.IsEmpty &&
_fileWatcher.EnableRaisingEvents)
{
// Perf: Turn off the file monitoring if no files to monitor.
_fileWatcher.EnableRaisingEvents = false;
}
}
}
}
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
private void TryEnableFileSystemWatcher()
{
if (_fileWatcher != null)
{
lock (_fileWatcherLock)
{
if ((!_filePathTokenLookup.IsEmpty || !_wildcardTokenLookup.IsEmpty) &&
!_fileWatcher.EnableRaisingEvents)
{
// Perf: Turn off the file monitoring if no files to monitor.
_fileWatcher.EnableRaisingEvents = true;
}
}
}
}
private static string NormalizePath(string filter) => filter.Replace('\\', '/');
private static bool IsDirectoryPath(string path)
{
return path.Length > 0 &&
(path[path.Length - 1] == Path.DirectorySeparatorChar ||
path[path.Length - 1] == Path.AltDirectorySeparatorChar);
}
private static void CancelToken(ChangeTokenInfo matchInfo)
{
if (matchInfo.TokenSource.IsCancellationRequested)
{
return;
}
Task.Factory.StartNew(
_cancelTokenSource,
matchInfo.TokenSource,
CancellationToken.None,
TaskCreationOptions.DenyChildAttach,
TaskScheduler.Default);
}
internal static void RaiseChangeEvents(object? state)
{
Debug.Assert(state != null);
// Iterating over a concurrent bag gives us a point in time snapshot making it safe
// to remove items from it.
var changeTokens = (ConcurrentDictionary<IPollingChangeToken, IPollingChangeToken>)state;
foreach (System.Collections.Generic.KeyValuePair<IPollingChangeToken, IPollingChangeToken> item in changeTokens)
{
IPollingChangeToken token = item.Key;
if (!token.HasChanged)
{
continue;
}
if (!changeTokens.TryRemove(token, out _))
{
// Move on if we couldn't remove the item.
continue;
}
// We're already on a background thread, don't need to spawn a background Task to cancel the CTS
try
{
token.CancellationTokenSource!.Cancel();
}
catch
{
}
}
}
private readonly struct ChangeTokenInfo
{
public ChangeTokenInfo(
CancellationTokenSource tokenSource,
CancellationChangeToken changeToken)
: this(tokenSource, changeToken, matcher: null)
{
}
public ChangeTokenInfo(
CancellationTokenSource tokenSource,
CancellationChangeToken changeToken,
Matcher? matcher)
{
TokenSource = tokenSource;
ChangeToken = changeToken;
Matcher = matcher;
}
public CancellationTokenSource TokenSource { get; }
public CancellationChangeToken ChangeToken { get; }
public Matcher? Matcher { get; }
}
}
}
|