Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/BuiltInTools/dotnet-watch/DotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
using var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, currentRunCancellationSource.Token);
using var fileSetWatcher = new FileWatcher(Context.Reporter);

fileSetWatcher.WatchContainingDirectories(evaluationResult.Files.Keys);
fileSetWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);

var processTask = ProcessRunner.RunAsync(processSpec, Context.Reporter, isUserApplication: true, launchResult: null, combinedCancellationSource.Token);

Expand Down
16 changes: 5 additions & 11 deletions src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
return;
}

fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys);
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);

var changedFilesAccumulator = ImmutableList<ChangedPath>.Empty;

Expand Down Expand Up @@ -441,7 +441,7 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
evaluationResult = await EvaluateRootProjectAsync(iterationCancellationToken);

// additional directories may have been added:
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys);
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);

await compilationHandler.Workspace.UpdateProjectConeAsync(RootFileSetFactory.RootProjectFile, iterationCancellationToken);

Expand Down Expand Up @@ -554,7 +554,7 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
{
if (!fileWatcher.WatchingDirectories)
{
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys);
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
}

_ = await fileWatcher.WaitForFileChangeAsync(
Expand All @@ -564,8 +564,8 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
}
else
{
// evaluation cancelled - watch for any changes in the directory containing the root project:
fileWatcher.WatchContainingDirectories([RootFileSetFactory.RootProjectFile]);
// evaluation cancelled - watch for any changes in the directory tree containing the root project:
fileWatcher.WatchContainingDirectories([RootFileSetFactory.RootProjectFile], includeSubdirectories: true);

_ = await fileWatcher.WaitForFileChangeAsync(
acceptChange: change => AcceptChange(change),
Expand Down Expand Up @@ -605,12 +605,6 @@ private bool AcceptChange(ChangedPath change)
{
var (path, kind) = change;

// only handle file changes:
if (Directory.Exists(path))
{
return false;
}

if (PathUtilities.GetContainingDirectories(path).FirstOrDefault(IsHiddenDirectory) is { } containingHiddenDir)
{
Context.Reporter.Report(MessageDescriptor.IgnoringChangeInHiddenDirectory, containingHiddenDir, kind, path);
Expand Down
85 changes: 46 additions & 39 deletions src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ namespace Microsoft.DotNet.Watch
{
internal sealed class FileWatcher(IReporter reporter) : IDisposable
{
// Directory watcher for each watched directory
private readonly Dictionary<string, IDirectoryWatcher> _watchers = [];
// Directory watcher for each watched directory tree.
// Keyed by full path to the root directory with a trailing directory separator.
private readonly Dictionary<string, IDirectoryWatcher> _directoryTreeWatchers = new(PathUtilities.OSSpecificPathComparer);

// Directory watcher for each watched directory (non-recursive).
// Keyed by full path to the root directory with a trailing directory separator.
private readonly Dictionary<string, IDirectoryWatcher> _directoryWatchers = new(PathUtilities.OSSpecificPathComparer);

private bool _disposed;
public event Action<ChangedPath>? OnFileChange;
Expand All @@ -22,7 +27,7 @@ public void Dispose()

_disposed = true;

foreach (var (_, watcher) in _watchers)
foreach (var (_, watcher) in _directoryTreeWatchers)
{
watcher.OnFileChange -= WatcherChangedHandler;
watcher.OnError -= WatcherErrorHandler;
Expand All @@ -31,39 +36,33 @@ public void Dispose()
}

public bool WatchingDirectories
=> _watchers.Count > 0;
=> _directoryTreeWatchers.Count > 0 || _directoryWatchers.Count > 0;

public void WatchContainingDirectories(IEnumerable<string> filePaths)
=> WatchDirectories(filePaths.Select(path => Path.GetDirectoryName(path)!));
public void WatchContainingDirectories(IEnumerable<string> filePaths, bool includeSubdirectories)
=> WatchDirectories(filePaths.Select(path => Path.GetDirectoryName(path)!), includeSubdirectories);

public void WatchDirectories(IEnumerable<string> directories)
public void WatchDirectories(IEnumerable<string> directories, bool includeSubdirectories)
{
ObjectDisposedException.ThrowIf(_disposed, this);

foreach (var dir in directories)
foreach (var dir in directories.Distinct())
{
var directory = EnsureTrailingSlash(dir);

var alreadyWatched = _watchers
.Where(d => directory.StartsWith(d.Key))
.Any();
var directory = PathUtilities.EnsureTrailingSlash(PathUtilities.NormalizeDirectorySeparators(dir));

if (alreadyWatched)
// the directory is watched by active directory watcher:
if (!includeSubdirectories && _directoryWatchers.ContainsKey(directory))
{
continue;
}

var redundantWatchers = _watchers
.Where(d => d.Key.StartsWith(directory))
.Select(d => d.Key)
.ToList();

foreach (var watcher in redundantWatchers)
// the directory is a root or subdirectory of active directory tree watcher:
var alreadyWatched = _directoryTreeWatchers.Any(d => directory.StartsWith(d.Key, PathUtilities.OSSpecificPathComparison));
if (alreadyWatched)
{
DisposeWatcher(watcher);
continue;
}

var newWatcher = FileWatcherFactory.CreateWatcher(directory);
var newWatcher = FileWatcherFactory.CreateWatcher(directory, includeSubdirectories);
if (newWatcher is EventBasedDirectoryWatcher eventBasedWatcher)
{
eventBasedWatcher.Logger = message => reporter.Verbose(message);
Expand All @@ -73,7 +72,30 @@ public void WatchDirectories(IEnumerable<string> directories)
newWatcher.OnError += WatcherErrorHandler;
newWatcher.EnableRaisingEvents = true;

_watchers.Add(directory, newWatcher);
// watchers that are now redundant (covered by the new directory watcher):
if (includeSubdirectories)
{
var watchersToRemove = _directoryTreeWatchers
.Where(d => d.Key.StartsWith(directory, PathUtilities.OSSpecificPathComparison))
.ToList();

foreach (var (watchedDirectory, watcher) in watchersToRemove)
{
_directoryTreeWatchers.Remove(watchedDirectory);

watcher.EnableRaisingEvents = false;
watcher.OnFileChange -= WatcherChangedHandler;
watcher.OnError -= WatcherErrorHandler;

watcher.Dispose();
}

_directoryTreeWatchers.Add(directory, newWatcher);
}
else
{
_directoryWatchers.Add(directory, newWatcher);
}
}
}

Expand All @@ -93,21 +115,6 @@ private void WatcherChangedHandler(object? sender, ChangedPath change)
}
}

private void DisposeWatcher(string directory)
{
var watcher = _watchers[directory];
_watchers.Remove(directory);

watcher.EnableRaisingEvents = false;
watcher.OnFileChange -= WatcherChangedHandler;
watcher.OnError -= WatcherErrorHandler;

watcher.Dispose();
}

private static string EnsureTrailingSlash(string path)
=> (path is [.., var last] && last != Path.DirectorySeparatorChar) ? path + Path.DirectorySeparatorChar : path;

public async Task<ChangedFile?> WaitForFileChangeAsync(IReadOnlyDictionary<string, FileItem> fileSet, Action? startedWatching, CancellationToken cancellationToken)
{
var changedPath = await WaitForFileChangeAsync(
Expand Down Expand Up @@ -151,7 +158,7 @@ public static async ValueTask WaitForFileChangeAsync(string filePath, IReporter
{
using var watcher = new FileWatcher(reporter);

watcher.WatchDirectories([Path.GetDirectoryName(filePath)!]);
watcher.WatchContainingDirectories([filePath], includeSubdirectories: false);

var fileChange = await watcher.WaitForFileChangeAsync(
acceptChange: change => change.Path == filePath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,21 @@ namespace Microsoft.DotNet.Watch
internal sealed class EventBasedDirectoryWatcher : IDirectoryWatcher
{
public event EventHandler<ChangedPath>? OnFileChange;

public event EventHandler<Exception>? OnError;

public string WatchedDirectory { get; }

internal Action<string>? Logger { get; set; }
public bool IncludeSubdirectories { get; }
public Action<string>? Logger { get; set; }

private volatile bool _disposed;

private FileSystemWatcher? _fileSystemWatcher;
private readonly Lock _createLock = new();

private readonly object _createLock = new();

internal EventBasedDirectoryWatcher(string watchedDirectory)
internal EventBasedDirectoryWatcher(string watchedDirectory, bool includeSubdirectories)
{
WatchedDirectory = watchedDirectory;
IncludeSubdirectories = includeSubdirectories;

CreateFileSystemWatcher();
}

Expand All @@ -40,7 +39,7 @@ private void WatcherErrorHandler(object sender, ErrorEventArgs e)
return;
}

Logger?.Invoke("Error");
Logger?.Invoke("[FW] Error");

var exception = e.GetException();

Expand All @@ -65,21 +64,23 @@ private void WatcherRenameHandler(object sender, RenamedEventArgs e)
return;
}

Logger?.Invoke($"Renamed '{e.OldFullPath}' to '{e.FullPath}'.");

NotifyChange(e.OldFullPath, ChangeKind.Delete);
NotifyChange(e.FullPath, ChangeKind.Add);
Logger?.Invoke($"[FW] Renamed '{e.OldFullPath}' to '{e.FullPath}'.");

if (Directory.Exists(e.FullPath))
{
foreach (var newLocation in Directory.EnumerateFileSystemEntries(e.FullPath, "*", SearchOption.AllDirectories))
foreach (var newLocation in Directory.EnumerateFiles(e.FullPath, "*", SearchOption.AllDirectories))
{
// Calculated previous path of this moved item.
var oldLocation = Path.Combine(e.OldFullPath, newLocation.Substring(e.FullPath.Length + 1));
NotifyChange(oldLocation, ChangeKind.Delete);
NotifyChange(newLocation, ChangeKind.Add);
}
}
else
{
NotifyChange(e.OldFullPath, ChangeKind.Delete);
NotifyChange(e.FullPath, ChangeKind.Add);
}
}

private void WatcherDeletedHandler(object sender, FileSystemEventArgs e)
Expand All @@ -89,7 +90,16 @@ private void WatcherDeletedHandler(object sender, FileSystemEventArgs e)
return;
}

Logger?.Invoke($"Deleted '{e.FullPath}'.");
var isDir = Directory.Exists(e.FullPath);

Logger?.Invoke($"[FW] Deleted '{e.FullPath}'.");

// ignore directory changes:
if (isDir)
{
return;
}

NotifyChange(e.FullPath, ChangeKind.Delete);
}

Expand All @@ -100,7 +110,16 @@ private void WatcherChangeHandler(object sender, FileSystemEventArgs e)
return;
}

Logger?.Invoke($"Updated '{e.FullPath}'.");
var isDir = Directory.Exists(e.FullPath);

Logger?.Invoke($"[FW] Updated '{e.FullPath}'.");

// ignore directory changes:
if (isDir)
{
return;
}

NotifyChange(e.FullPath, ChangeKind.Update);
}

Expand All @@ -111,7 +130,15 @@ private void WatcherAddedHandler(object sender, FileSystemEventArgs e)
return;
}

Logger?.Invoke($"Added '{e.FullPath}'.");
var isDir = Directory.Exists(e.FullPath);

Logger?.Invoke($"[FW] Added '{e.FullPath}'.");

if (isDir)
{
return;
}

NotifyChange(e.FullPath, ChangeKind.Add);
}

Expand All @@ -136,7 +163,7 @@ private void CreateFileSystemWatcher()

_fileSystemWatcher = new FileSystemWatcher(WatchedDirectory)
{
IncludeSubdirectories = true
IncludeSubdirectories = IncludeSubdirectories
};

_fileSystemWatcher.Created += WatcherAddedHandler;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ namespace Microsoft.DotNet.Watch
{
internal static class FileWatcherFactory
{
public static IDirectoryWatcher CreateWatcher(string watchedDirectory)
=> CreateWatcher(watchedDirectory, EnvironmentVariables.IsPollingEnabled);
public static IDirectoryWatcher CreateWatcher(string watchedDirectory, bool includeSubdirectories)
=> CreateWatcher(watchedDirectory, EnvironmentVariables.IsPollingEnabled, includeSubdirectories);

public static IDirectoryWatcher CreateWatcher(string watchedDirectory, bool usePollingWatcher)
public static IDirectoryWatcher CreateWatcher(string watchedDirectory, bool usePollingWatcher, bool includeSubdirectories)
{
return usePollingWatcher ?
new PollingDirectoryWatcher(watchedDirectory) :
new EventBasedDirectoryWatcher(watchedDirectory);
new PollingDirectoryWatcher(watchedDirectory, includeSubdirectories) :
new EventBasedDirectoryWatcher(watchedDirectory, includeSubdirectories);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.DotNet.Watch
namespace Microsoft.DotNet.Watch;

/// <summary>
/// Watches for changes in a <see cref="WatchedDirectory"/> and its subdirectories.
/// </summary>
internal interface IDirectoryWatcher : IDisposable
{
internal interface IDirectoryWatcher : IDisposable
{
event EventHandler<ChangedPath> OnFileChange;
event EventHandler<ChangedPath> OnFileChange;

event EventHandler<Exception> OnError;
event EventHandler<Exception> OnError;

string WatchedDirectory { get; }
string WatchedDirectory { get; }

bool EnableRaisingEvents { get; set; }
}
bool EnableRaisingEvents { get; set; }
}
Loading
Loading