diff --git a/src/BuiltInTools/dotnet-watch/FileItem.cs b/src/BuiltInTools/dotnet-watch/FileItem.cs
index 8e0a028d4d09..acf044055fae 100644
--- a/src/BuiltInTools/dotnet-watch/FileItem.cs
+++ b/src/BuiltInTools/dotnet-watch/FileItem.cs
@@ -10,15 +10,12 @@ internal readonly record struct FileItem
///
/// List of all projects that contain this file (does not contain duplicates).
- /// Empty if is and the
- /// item has not been assigned to a project yet.
+ /// Empty if the item is added but not been assigned to a project yet.
///
public required List ContainingProjectPaths { get; init; }
public string? StaticWebAssetPath { get; init; }
- public ChangeKind Change { get; init; }
-
public bool IsStaticFile => StaticWebAssetPath != null;
}
}
diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs
index a2c8b0a906ff..838c3f97ab70 100644
--- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs
+++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs
@@ -364,7 +364,7 @@ private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates update
switch (updates.Status)
{
case ModuleUpdateStatus.None:
- _reporter.Output("No C# changes to apply.");
+ _reporter.Report(MessageDescriptor.NoHotReloadChangesToApply);
break;
case ModuleUpdateStatus.Ready:
diff --git a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs
index 164224e9876c..f4433fe8a374 100644
--- a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs
+++ b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs
@@ -3,14 +3,13 @@
using System.Collections.Immutable;
using System.Diagnostics;
+using Microsoft.Build.Graph;
using Microsoft.CodeAnalysis;
namespace Microsoft.DotNet.Watch
{
internal sealed partial class HotReloadDotNetWatcher : Watcher
{
- private static readonly DateTime s_fileNotExistFileTime = DateTime.FromFileTime(0);
-
private readonly IConsole _console;
private readonly IRuntimeProcessLauncherFactory? _runtimeProcessLauncherFactory;
private readonly RestartPrompt? _rudeEditRestartPrompt;
@@ -76,7 +75,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
Task>? fileWatcherTask = null;
IRuntimeProcessLauncher? runtimeProcessLauncher = null;
CompilationHandler? compilationHandler = null;
- Action? fileChangedCallback = null;
+ Action? fileChangedCallback = null;
try
{
@@ -99,13 +98,15 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
runtimeProcessLauncherFactory ??= AspireServiceFactory.Instance;
Context.Reporter.Verbose("Using Aspire process launcher.");
}
-
+
await using var browserConnector = new BrowserConnector(Context);
var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, Context.Reporter);
compilationHandler = new CompilationHandler(Context.Reporter);
var staticFileHandler = new StaticFileHandler(Context.Reporter, projectMap, browserConnector);
var scopedCssFileHandler = new ScopedCssFileHandler(Context.Reporter, projectMap, browserConnector);
var projectLauncher = new ProjectLauncher(Context, projectMap, browserConnector, compilationHandler, iteration);
+ var outputDirectories = GetProjectOutputDirectories(evaluationResult.ProjectGraph);
+ var changeFilter = new Predicate(change => AcceptChange(change, evaluationResult, outputDirectories));
var rootProjectNode = evaluationResult.ProjectGraph.GraphRoots.Single();
@@ -166,7 +167,6 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
return;
}
- var buildCompletionTime = DateTime.UtcNow;
await compilationHandler.Workspace.UpdateProjectConeAsync(RootFileSetFactory.RootProjectFile, iterationCancellationToken);
// Solution must be initialized after we load the solution but before we start watching for file changes to avoid race condition
@@ -182,13 +182,14 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys);
- var changedFilesAccumulator = ImmutableList.Empty;
+ var changedFilesAccumulator = ImmutableList.Empty;
- void FileChangedCallback(string path, ChangeKind kind)
+ void FileChangedCallback(ChangedPath change)
{
- if (TryGetChangedFile(evaluationResult.Files, buildCompletionTime, path, kind) is { } changedFile)
+ if (changeFilter(change))
{
- ImmutableInterlocked.Update(ref changedFilesAccumulator, changedFiles => changedFiles.Add(changedFile));
+ Context.Reporter.Verbose($"File change: {change.Kind} '{change.Path}'.");
+ ImmutableInterlocked.Update(ref changedFilesAccumulator, changedPaths => changedPaths.Add(change));
}
}
@@ -197,13 +198,14 @@ void FileChangedCallback(string path, ChangeKind kind)
ReportWatchingForChanges();
// Hot Reload loop - exits when the root process needs to be restarted.
+ bool extendTimeout = false;
while (true)
{
try
{
// Use timeout to batch file changes. If the process doesn't exit within the given timespan we'll check
// for accumulated file changes. If there are any we attempt Hot Reload. Otherwise we come back here to wait again.
- _ = await rootRunningProject.RunningProcess.WaitAsync(TimeSpan.FromMilliseconds(50), iterationCancellationToken);
+ _ = await rootRunningProject.RunningProcess.WaitAsync(TimeSpan.FromMilliseconds(extendTimeout ? 200 : 50), iterationCancellationToken);
// Process exited: cancel the iteration, but wait for a file change before starting a new one
waitForFileChangeBeforeRestarting = true;
@@ -221,6 +223,16 @@ void FileChangedCallback(string path, ChangeKind kind)
break;
}
+ // If the changes include addition wait a little bit more for possible matching deletion.
+ // This eliminates reevaluations caused by teared temp file add/delete change pair.
+ if (!extendTimeout && changedFilesAccumulator.Any(change => change.Kind == ChangeKind.Add))
+ {
+ extendTimeout = true;
+ continue;
+ }
+
+ extendTimeout = false;
+
var changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: null);
if (changedFiles is [])
{
@@ -315,7 +327,7 @@ void FileChangedCallback(string path, ChangeKind kind)
iterationCancellationToken.ThrowIfCancellationRequested();
// pause accumulating file changes during build:
- fileWatcher.OnFileChange -= fileChangedCallback;
+ fileWatcher.SuppressEvents = true;
try
{
var buildResults = await Task.WhenAll(
@@ -328,19 +340,17 @@ void FileChangedCallback(string path, ChangeKind kind)
}
finally
{
- fileWatcher.OnFileChange += fileChangedCallback;
+ fileWatcher.SuppressEvents = false;
}
iterationCancellationToken.ThrowIfCancellationRequested();
_ = await fileWatcher.WaitForFileChangeAsync(
+ changeFilter,
startedWatching: () => Context.Reporter.Report(MessageDescriptor.FixBuildError),
shutdownCancellationToken);
}
- // Update build completion time, so that file changes caused by the rebuild do not affect our file watcher:
- buildCompletionTime = DateTime.UtcNow;
-
// Changes made since last snapshot of the accumulator shouldn't be included in next Hot Reload update.
// Apply them to the workspace.
_ = await CaptureChangedFilesSnapshot(projectsToRebuild);
@@ -374,20 +384,33 @@ await Task.WhenAll(
async Task> CaptureChangedFilesSnapshot(ImmutableDictionary? rebuiltProjects)
{
- var changedFiles = Interlocked.Exchange(ref changedFilesAccumulator, []);
- if (changedFiles is [])
+ var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []);
+ if (changedPaths is [])
{
return [];
}
+ // Note:
+ // It is possible that we could have received multiple changes for a file that should cancel each other (such as Delete + Add),
+ // but they end up split into two snapshots and we will interpret them as two separate Delete and Add changes that trigger
+ // two sets of Hot Reload updates. Hence the normalization is best effort as we can't predict future.
+
+ var changedFiles = NormalizePathChanges(changedPaths)
+ .Select(changedPath => new ChangedFile(
+ evaluationResult.Files.TryGetValue(changedPath.Path, out var fileItem)
+ ? fileItem
+ : new FileItem() { FilePath = changedPath.Path, ContainingProjectPaths = [] },
+ changedPath.Kind))
+ .ToImmutableList();
+
// When a new file is added we need to run design-time build to find out
// what kind of the file it is and which project(s) does it belong to (can be linked, web asset, etc.).
// We don't need to rebuild and restart the application though.
- var hasAddedFile = changedFiles.Any(f => f.Change is ChangeKind.Add);
+ var hasAddedFile = changedFiles.Any(f => f.Kind is ChangeKind.Add);
if (hasAddedFile)
{
- Context.Reporter.Verbose("File addition triggered re-evaluation.");
+ Context.Reporter.Report(MessageDescriptor.FileAdditionTriggeredReEvaluation);
evaluationResult = await EvaluateRootProjectAsync(iterationCancellationToken);
@@ -415,7 +438,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableDict
// and be included in the next Hot Reload change set.
var rebuiltProjectPaths = rebuiltProjects.Values.ToHashSet();
- var newAccumulator = ImmutableList.Empty;
+ var newAccumulator = ImmutableList.Empty;
var newChangedFiles = ImmutableList.Empty;
foreach (var file in changedFiles)
@@ -426,7 +449,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableDict
}
else
{
- newAccumulator = newAccumulator.Add(file);
+ newAccumulator = newAccumulator.Add(new ChangedPath(file.Item.FilePath, file.Kind));
}
}
@@ -533,78 +556,153 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
fileWatcher.WatchContainingDirectories([RootFileSetFactory.RootProjectFile]);
_ = await fileWatcher.WaitForFileChangeAsync(
+ acceptChange: change => AcceptChange(change),
startedWatching: () => Context.Reporter.Report(MessageDescriptor.WaitingForFileChangeBeforeRestarting),
cancellationToken);
}
}
- private ChangedFile? TryGetChangedFile(IReadOnlyDictionary fileSet, DateTime buildCompletionTime, string path, ChangeKind kind)
+ private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult, IReadOnlySet outputDirectories)
{
- // only handle file changes:
- if (Directory.Exists(path))
+ var (path, kind) = change;
+
+ // Handler changes to files that are known to be project build inputs from its evaluation.
+ if (evaluationResult.Files.ContainsKey(path))
{
- return null;
+ return true;
}
- if (kind != ChangeKind.Delete)
+ // Ignore other changes to output and intermediate output directories.
+ //
+ // Unsupported scenario:
+ // - msbuild target adds source files to intermediate output directory and Compile items
+ // based on the content of non-source file.
+ //
+ // On the other hand, changes to source files produced by source generators will be registered
+ // since the changes to additional file will trigger workspace update, which will trigger the source generator.
+ if (PathUtilities.ContainsPath(outputDirectories, path))
{
- try
- {
- // Do not report changes to files that happened during build:
- var creationTime = File.GetCreationTimeUtc(path);
- var writeTime = File.GetLastWriteTimeUtc(path);
+ Context.Reporter.Report(MessageDescriptor.IgnoringChangeInOutputDirectory, kind, path);
+ return false;
+ }
- if (creationTime == s_fileNotExistFileTime || writeTime == s_fileNotExistFileTime)
- {
- // file might have been deleted since we received the event
- kind = ChangeKind.Delete;
- }
- else if (creationTime.Ticks < buildCompletionTime.Ticks && writeTime.Ticks < buildCompletionTime.Ticks)
- {
- Context.Reporter.Verbose(
- $"Ignoring file change during build: {kind} '{path}' " +
- $"(created {FormatTimestamp(creationTime)} and written {FormatTimestamp(writeTime)} before {FormatTimestamp(buildCompletionTime)}).");
+ return AcceptChange(change);
+ }
- return null;
- }
- else if (writeTime > creationTime)
- {
- Context.Reporter.Verbose($"File change: {kind} '{path}' (written {FormatTimestamp(writeTime)} after {FormatTimestamp(buildCompletionTime)}).");
- }
- else
- {
- Context.Reporter.Verbose($"File change: {kind} '{path}' (created {FormatTimestamp(creationTime)} after {FormatTimestamp(buildCompletionTime)}).");
- }
- }
- catch (Exception e)
- {
- Context.Reporter.Verbose($"Ignoring file '{path}' due to access error: {e.Message}.");
- return null;
- }
- }
+ private bool AcceptChange(ChangedPath change)
+ {
+ var (path, kind) = change;
- if (kind == ChangeKind.Delete)
+ // only handle file changes:
+ if (Directory.Exists(path))
{
- Context.Reporter.Verbose($"File '{path}' deleted after {FormatTimestamp(buildCompletionTime)}.");
+ return false;
}
- if (fileSet.TryGetValue(path, out var fileItem))
+ if (PathUtilities.GetContainingDirectories(path).FirstOrDefault(IsHiddenPath) is { } containingHiddenDir)
{
- // For some reason we are sometimes seeing Add events raised whan an existing file is updated:
- return new ChangedFile(fileItem, (kind == ChangeKind.Add) ? ChangeKind.Update : kind);
+ Context.Reporter.Report(MessageDescriptor.IgnoringChangeInHiddenDirectory, containingHiddenDir, kind, path);
+ return false;
}
- if (kind == ChangeKind.Add)
+ return true;
+ }
+
+ private static bool IsHiddenPath(string path)
+ // Note: the device root directory on Windows has hidden attribute:
+ => File.GetAttributes(path).HasFlag(FileAttributes.Hidden) && Path.GetDirectoryName(path) != null;
+
+ internal static string FormatTimestamp(DateTime time)
+ => time.ToString("HH:mm:ss.fffffff");
+
+ private static IReadOnlySet GetProjectOutputDirectories(ProjectGraph projectGraph)
+ {
+ var projectOutputDirectories = new HashSet(PathUtilities.OSSpecificPathComparer);
+
+ foreach (var projectNode in projectGraph.ProjectNodes)
{
- return new ChangedFile(new FileItem { FilePath = path, ContainingProjectPaths = [] }, kind);
+ if (projectNode.GetOutputDirectory() is { } outputDir)
+ {
+ projectOutputDirectories.Add(Path.TrimEndingDirectorySeparator(outputDir));
+ }
+
+ if (projectNode.GetIntermediateOutputDirectory() is { } intermediateDir)
+ {
+ projectOutputDirectories.Add(Path.TrimEndingDirectorySeparator(intermediateDir));
+ }
}
- Context.Reporter.Verbose($"Change ignored: {kind} '{path}'.");
- return null;
+ return projectOutputDirectories;
}
- internal static string FormatTimestamp(DateTime time)
- => time.ToString("HH:mm:ss.fffffff");
+ internal static IEnumerable NormalizePathChanges(IEnumerable changes)
+ => changes
+ .GroupBy(keySelector: change => change.Path)
+ .Select(group =>
+ {
+ ChangedPath? lastUpdate = null;
+ ChangedPath? lastDelete = null;
+ ChangedPath? lastAdd = null;
+ ChangedPath? previous = null;
+
+ foreach (var item in group)
+ {
+ // eliminate repeated changes:
+ if (item.Kind == previous?.Kind)
+ {
+ continue;
+ }
+
+ previous = item;
+
+ if (item.Kind == ChangeKind.Add)
+ {
+ // eliminate delete-(update)*-add:
+ if (lastDelete.HasValue)
+ {
+ lastDelete = null;
+ lastAdd = null;
+ lastUpdate ??= item with { Kind = ChangeKind.Update };
+ }
+ else if (!lastUpdate.HasValue) // ignore add after update, but only if it is not preceded by delete
+ {
+ lastAdd = item;
+ }
+ }
+ else if (item.Kind == ChangeKind.Delete)
+ {
+ // eliminate add-delete:
+ if (lastAdd.HasValue)
+ {
+ lastDelete = null;
+ lastAdd = null;
+ }
+ else
+ {
+ lastDelete = item;
+
+ // eliminate previous update:
+ lastUpdate = null;
+ }
+ }
+ else if (item.Kind == ChangeKind.Update)
+ {
+ // ignore updates after add:
+ if (!lastAdd.HasValue)
+ {
+ lastUpdate = item;
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException($"Unexpected change kind: {item.Kind}");
+ }
+ }
+
+ return lastDelete ?? lastAdd ?? lastUpdate;
+ })
+ .Where(item => item != null)
+ .Select(item => item!.Value);
private void ReportWatchingForChanges()
{
@@ -625,7 +723,7 @@ private void ReportFileChanges(IReadOnlyList changedFiles)
void Report(ChangeKind kind)
{
- var items = changedFiles.Where(item => item.Change == kind).ToArray();
+ var items = changedFiles.Where(item => item.Kind == kind).ToArray();
if (items is not [])
{
Context.Reporter.Output(GetMessage(items, kind));
diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs
index 94d4323096f4..8a85ceab502f 100644
--- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs
+++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs
@@ -9,7 +9,9 @@ internal sealed class FileWatcher(IReporter reporter) : IDisposable
private readonly Dictionary _watchers = [];
private bool _disposed;
- public event Action? OnFileChange;
+ public event Action? OnFileChange;
+
+ public bool SuppressEvents { get; set; }
public void Dispose()
{
@@ -78,9 +80,12 @@ private void WatcherErrorHandler(object? sender, Exception error)
}
}
- private void WatcherChangedHandler(object? sender, (string changedPath, ChangeKind kind) args)
+ private void WatcherChangedHandler(object? sender, ChangedPath change)
{
- OnFileChange?.Invoke(args.changedPath, args.kind);
+ if (!SuppressEvents)
+ {
+ OnFileChange?.Invoke(change);
+ }
}
private void DisposeWatcher(string directory)
@@ -98,45 +103,43 @@ private void DisposeWatcher(string directory)
private static string EnsureTrailingSlash(string path)
=> (path is [.., var last] && last != Path.DirectorySeparatorChar) ? path + Path.DirectorySeparatorChar : path;
- public Task WaitForFileChangeAsync(Action? startedWatching, CancellationToken cancellationToken)
- => WaitForFileChangeAsync(
- changeFilter: (path, kind) => new ChangedFile(new FileItem() { FilePath = path, ContainingProjectPaths = [] }, kind),
- startedWatching,
- cancellationToken);
-
- public Task WaitForFileChangeAsync(IReadOnlyDictionary fileSet, Action? startedWatching, CancellationToken cancellationToken)
- => WaitForFileChangeAsync(
- changeFilter: (path, kind) => fileSet.TryGetValue(path, out var fileItem) ? new ChangedFile(fileItem, kind) : null,
+ public async Task WaitForFileChangeAsync(IReadOnlyDictionary fileSet, Action? startedWatching, CancellationToken cancellationToken)
+ {
+ var changedPath = await WaitForFileChangeAsync(
+ acceptChange: change => fileSet.ContainsKey(change.Path),
startedWatching,
cancellationToken);
- public async Task WaitForFileChangeAsync(Func changeFilter, Action? startedWatching, CancellationToken cancellationToken)
+ return changedPath.HasValue ? new ChangedFile(fileSet[changedPath.Value.Path], changedPath.Value.Kind) : null;
+ }
+
+ public async Task WaitForFileChangeAsync(Predicate acceptChange, Action? startedWatching, CancellationToken cancellationToken)
{
- var fileChangedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var fileChangedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
cancellationToken.Register(() => fileChangedSource.TrySetResult(null));
- void FileChangedCallback(string path, ChangeKind kind)
+ void FileChangedCallback(ChangedPath change)
{
- if (changeFilter(path, kind) is { } changedFile)
+ if (acceptChange(change))
{
- fileChangedSource.TrySetResult(changedFile);
+ fileChangedSource.TrySetResult(change);
}
}
- ChangedFile? changedFile;
+ ChangedPath? change;
OnFileChange += FileChangedCallback;
try
{
startedWatching?.Invoke();
- changedFile = await fileChangedSource.Task;
+ change = await fileChangedSource.Task;
}
finally
{
OnFileChange -= FileChangedCallback;
}
- return changedFile;
+ return change;
}
public static async ValueTask WaitForFileChangeAsync(string filePath, IReporter reporter, Action? startedWatching, CancellationToken cancellationToken)
@@ -146,7 +149,7 @@ public static async ValueTask WaitForFileChangeAsync(string filePath, IReporter
watcher.WatchDirectories([Path.GetDirectoryName(filePath)!]);
var fileChange = await watcher.WaitForFileChangeAsync(
- changeFilter: (path, kind) => path == filePath ? new ChangedFile(new FileItem { FilePath = path, ContainingProjectPaths = [] }, kind) : null,
+ acceptChange: change => change.Path == filePath,
startedWatching,
cancellationToken);
diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/ChangeKind.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/ChangeKind.cs
index ca4cd28c9171..71e339419a1c 100644
--- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/ChangeKind.cs
+++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/ChangeKind.cs
@@ -10,4 +10,6 @@ internal enum ChangeKind
Delete
}
-internal readonly record struct ChangedFile(FileItem Item, ChangeKind Change);
+internal readonly record struct ChangedFile(FileItem Item, ChangeKind Kind);
+
+internal readonly record struct ChangedPath(string Path, ChangeKind Kind);
diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs
index 508475247cc7..4abf152e5e10 100644
--- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs
+++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs
@@ -7,7 +7,7 @@ namespace Microsoft.DotNet.Watch
{
internal sealed class EventBasedDirectoryWatcher : IDirectoryWatcher
{
- public event EventHandler<(string filePath, ChangeKind kind)>? OnFileChange;
+ public event EventHandler? OnFileChange;
public event EventHandler? OnError;
@@ -118,7 +118,7 @@ private void WatcherAddedHandler(object sender, FileSystemEventArgs e)
private void NotifyChange(string fullPath, ChangeKind kind)
{
// Only report file changes
- OnFileChange?.Invoke(this, (fullPath, kind));
+ OnFileChange?.Invoke(this, new ChangedPath(fullPath, kind));
}
private void CreateFileSystemWatcher()
diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs
index 6b1eb73671a9..4adff49b1425 100644
--- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs
+++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs
@@ -5,7 +5,7 @@ namespace Microsoft.DotNet.Watch
{
internal interface IDirectoryWatcher : IDisposable
{
- event EventHandler<(string filePath, ChangeKind kind)> OnFileChange;
+ event EventHandler OnFileChange;
event EventHandler OnError;
diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs
index 1477e7239783..a462944e3361 100644
--- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs
+++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs
@@ -21,7 +21,7 @@ internal sealed class PollingDirectoryWatcher : IDirectoryWatcher
private volatile bool _disposed;
- public event EventHandler<(string filePath, ChangeKind kind)>? OnFileChange;
+ public event EventHandler? OnFileChange;
#pragma warning disable CS0067 // not used
public event EventHandler? OnError;
@@ -212,7 +212,7 @@ private void NotifyChanges()
break;
}
- OnFileChange?.Invoke(this, (path, kind));
+ OnFileChange?.Invoke(this, new ChangedPath(path, kind));
}
}
diff --git a/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs b/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs
index 932c92d59fdd..e216f30af874 100644
--- a/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs
+++ b/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs
@@ -72,6 +72,10 @@ public bool TryGetMessage(string? prefix, object?[] args, [NotNullWhen(true)] ou
public static readonly MessageDescriptor ApplyUpdate_FileContentDoesNotMatchBuiltSource = new("{0} Expected if a source file is updated that is linked to project whose build is not up-to-date.", "⌚", MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor ConfiguredToLaunchBrowser = new("dotnet-watch is configured to launch a browser on ASP.NET Core application startup.", "⌚", MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = new("Configuring the app to use browser-refresh middleware", "⌚", MessageSeverity.Verbose, s_id++);
+ public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = new("Ignoring change in hidden directory '{0}': {1} '{2}'", "⌚", MessageSeverity.Verbose, s_id++);
+ public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = new("Ignoring change in output directory: {0} '{1}'", "⌚", MessageSeverity.Verbose, s_id++);
+ public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = new("File addition triggered re-evaluation.", "⌚", MessageSeverity.Verbose, s_id++);
+ public static readonly MessageDescriptor NoHotReloadChangesToApply = new ("No C# changes to apply.", "⌚", MessageSeverity.Output, s_id++);
}
internal interface IReporter
diff --git a/src/BuiltInTools/dotnet-watch/Utilities/PathUtilities.cs b/src/BuiltInTools/dotnet-watch/Utilities/PathUtilities.cs
new file mode 100644
index 000000000000..9d170b074cfa
--- /dev/null
+++ b/src/BuiltInTools/dotnet-watch/Utilities/PathUtilities.cs
@@ -0,0 +1,45 @@
+// 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;
+
+internal static class PathUtilities
+{
+ public static readonly IEqualityComparer OSSpecificPathComparer = Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
+
+ public static bool ContainsPath(IReadOnlySet directories, string fullPath)
+ {
+ fullPath = Path.TrimEndingDirectorySeparator(fullPath);
+
+ while (true)
+ {
+ if (directories.Contains(fullPath))
+ {
+ return true;
+ }
+
+ var containingDir = Path.GetDirectoryName(fullPath);
+ if (containingDir == null)
+ {
+ return false;
+ }
+
+ fullPath = containingDir;
+ }
+ }
+
+ public static IEnumerable GetContainingDirectories(string path)
+ {
+ while (true)
+ {
+ var containingDir = Path.GetDirectoryName(path);
+ if (containingDir == null)
+ {
+ yield break;
+ }
+
+ yield return containingDir;
+ path = containingDir;
+ }
+ }
+}
diff --git a/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs b/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs
index 9df4691e434c..d46945d500d9 100644
--- a/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs
+++ b/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs
@@ -33,6 +33,12 @@ public static bool IsNetCoreApp(this ProjectGraphNode projectNode)
public static bool IsNetCoreApp(this ProjectGraphNode projectNode, Version minVersion)
=> IsNetCoreApp(projectNode) && IsTargetFrameworkVersionOrNewer(projectNode, minVersion);
+ public static string? GetOutputDirectory(this ProjectGraphNode projectNode)
+ => projectNode.ProjectInstance.GetPropertyValue("TargetPath") is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(projectNode.ProjectInstance.Directory, path)) : null;
+
+ public static string? GetIntermediateOutputDirectory(this ProjectGraphNode projectNode)
+ => projectNode.ProjectInstance.GetPropertyValue("IntermediateOutputPath") is { Length: >0 } path ? Path.Combine(projectNode.ProjectInstance.Directory, path) : null;
+
public static IEnumerable GetCapabilities(this ProjectGraphNode projectNode)
=> projectNode.ProjectInstance.GetItems("ProjectCapability").Select(item => item.EvaluatedInclude);
diff --git a/test/dotnet-watch.Tests/Directory.Build.targets b/test/dotnet-watch.Tests/Directory.Build.targets
new file mode 100644
index 000000000000..6ae79f4c0f1b
--- /dev/null
+++ b/test/dotnet-watch.Tests/Directory.Build.targets
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/dotnet-watch.Tests/FileWatcherTests.cs b/test/dotnet-watch.Tests/FileWatcherTests.cs
index ab12f6c40f45..9848ec270df4 100644
--- a/test/dotnet-watch.Tests/FileWatcherTests.cs
+++ b/test/dotnet-watch.Tests/FileWatcherTests.cs
@@ -11,7 +11,7 @@ public class FileWatcherTests(ITestOutputHelper output)
private async Task TestOperation(
string dir,
- (string path, ChangeKind kind)[] expectedChanges,
+ ChangedPath[] expectedChanges,
bool usePolling,
Action operation)
{
@@ -22,18 +22,18 @@ private async Task TestOperation(
}
var changedEv = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var filesChanged = new HashSet<(string path, ChangeKind kind)>();
+ var filesChanged = new HashSet();
- EventHandler<(string path, ChangeKind kind)> handler = null;
+ EventHandler handler = null;
handler = (_, f) =>
{
if (filesChanged.Add(f))
{
- output.WriteLine($"Observed new {f.kind}: '{f.path}' ({filesChanged.Count} out of {expectedChanges.Length})");
+ output.WriteLine($"Observed new {f.Kind}: '{f.Path}' ({filesChanged.Count} out of {expectedChanges.Length})");
}
else
{
- output.WriteLine($"Already seen {f.kind}: '{f.path}'");
+ output.WriteLine($"Already seen {f.Kind}: '{f.Path}'");
}
if (filesChanged.Count == expectedChanges.Length)
@@ -58,7 +58,7 @@ private async Task TestOperation(
operation();
await changedEv.Task.TimeoutAfter(DefaultTimeout);
- AssertEx.SequenceEqual(expectedChanges, filesChanged.Order());
+ AssertEx.SequenceEqual(expectedChanges, filesChanged.Order(Comparer.Create((x, y) => (x.Path, x.Kind).CompareTo((y.Path, y.Kind)))));
}
[Theory]
@@ -73,15 +73,15 @@ public async Task NewFile(bool usePolling)
await TestOperation(
dir,
expectedChanges: !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !usePolling
- ? new[]
- {
- (testFileFullPath, ChangeKind.Update),
- (testFileFullPath, ChangeKind.Add),
- }
- : new[]
- {
- (testFileFullPath, ChangeKind.Add),
- },
+ ?
+ [
+ new(testFileFullPath, ChangeKind.Update),
+ new(testFileFullPath, ChangeKind.Add),
+ ]
+ :
+ [
+ new(testFileFullPath, ChangeKind.Add),
+ ],
usePolling,
() => File.WriteAllText(testFileFullPath, string.Empty));
}
@@ -99,16 +99,16 @@ public async Task NewFileInNewDirectory(bool usePolling)
await TestOperation(
dir,
expectedChanges: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !usePolling
- ? new[]
- {
- (newDir, ChangeKind.Add),
- (newFile, ChangeKind.Update),
- (newFile, ChangeKind.Add),
- }
- : new[]
- {
- (newDir, ChangeKind.Add),
- },
+ ?
+ [
+ new(newDir, ChangeKind.Add),
+ new(newFile, ChangeKind.Update),
+ new(newFile, ChangeKind.Add),
+ ]
+ :
+ [
+ new(newDir, ChangeKind.Add),
+ ],
usePolling,
() =>
{
@@ -129,7 +129,7 @@ public async Task ChangeFile(bool usePolling)
await TestOperation(
dir,
- expectedChanges: [(testFileFullPath, ChangeKind.Update)],
+ expectedChanges: [new(testFileFullPath, ChangeKind.Update)],
usePolling,
() => File.WriteAllText(testFileFullPath, string.Empty));
}
@@ -149,15 +149,15 @@ await TestOperation(
expectedChanges: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !usePolling ?
[
// On OSX events from before we started observing are reported as well.
- (srcFile, ChangeKind.Update),
- (srcFile, ChangeKind.Add),
- (srcFile, ChangeKind.Delete),
- (dstFile, ChangeKind.Add),
+ new(srcFile, ChangeKind.Update),
+ new(srcFile, ChangeKind.Add),
+ new(srcFile, ChangeKind.Delete),
+ new(dstFile, ChangeKind.Add),
]
:
[
- (srcFile, ChangeKind.Delete),
- (dstFile, ChangeKind.Add),
+ new(srcFile, ChangeKind.Delete),
+ new(dstFile, ChangeKind.Add),
],
usePolling,
() => File.Move(srcFile, dstFile));
@@ -177,9 +177,10 @@ public async Task FileInSubdirectory()
await TestOperation(
dir,
- expectedChanges: [
- (subdir, ChangeKind.Update),
- (testFileFullPath, ChangeKind.Update)
+ expectedChanges:
+ [
+ new(subdir, ChangeKind.Update),
+ new(testFileFullPath, ChangeKind.Update)
],
usePolling: true,
() => File.WriteAllText(testFileFullPath, string.Empty));
@@ -264,7 +265,7 @@ public async Task MultipleFiles(bool usePolling)
await TestOperation(
dir,
- expectedChanges: [(testFileFullPath, ChangeKind.Update)],
+ expectedChanges: [new(testFileFullPath, ChangeKind.Update)],
usePolling: true,
() => File.WriteAllText(testFileFullPath, string.Empty));
}
@@ -292,12 +293,12 @@ private async Task AssertFileChangeRaisesEvent(string directory, IDirectoryWatch
{
var changedEv = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var expectedPath = Path.Combine(directory, Path.GetRandomFileName());
- EventHandler<(string, ChangeKind)> handler = (_, f) =>
+ EventHandler handler = (_, f) =>
{
output.WriteLine("File changed: " + f);
try
{
- if (string.Equals(f.Item1, expectedPath, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(f.Path, expectedPath, StringComparison.OrdinalIgnoreCase))
{
changedEv.TrySetResult(0);
}
@@ -351,39 +352,39 @@ await TestOperation(
dir,
expectedChanges: usePolling ?
[
- (subdir, ChangeKind.Delete),
- (f1, ChangeKind.Delete),
- (f2, ChangeKind.Delete),
- (f3, ChangeKind.Delete),
+ new(subdir, ChangeKind.Delete),
+ new(f1, ChangeKind.Delete),
+ new(f2, ChangeKind.Delete),
+ new(f3, ChangeKind.Delete),
]
: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ?
[
- (subdir, ChangeKind.Add),
- (subdir, ChangeKind.Delete),
- (f1, ChangeKind.Update),
- (f1, ChangeKind.Add),
- (f1, ChangeKind.Delete),
- (f2, ChangeKind.Update),
- (f2, ChangeKind.Add),
- (f2, ChangeKind.Delete),
- (f3, ChangeKind.Update),
- (f3, ChangeKind.Add),
- (f3, ChangeKind.Delete),
+ new(subdir, ChangeKind.Add),
+ new(subdir, ChangeKind.Delete),
+ new(f1, ChangeKind.Update),
+ new(f1, ChangeKind.Add),
+ new(f1, ChangeKind.Delete),
+ new(f2, ChangeKind.Update),
+ new(f2, ChangeKind.Add),
+ new(f2, ChangeKind.Delete),
+ new(f3, ChangeKind.Update),
+ new(f3, ChangeKind.Add),
+ new(f3, ChangeKind.Delete),
]
: RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
[
- (subdir, ChangeKind.Update),
- (subdir, ChangeKind.Delete),
- (f1, ChangeKind.Delete),
- (f2, ChangeKind.Delete),
- (f3, ChangeKind.Delete),
+ new(subdir, ChangeKind.Update),
+ new(subdir, ChangeKind.Delete),
+ new(f1, ChangeKind.Delete),
+ new(f2, ChangeKind.Delete),
+ new(f3, ChangeKind.Delete),
]
:
[
- (subdir, ChangeKind.Delete),
- (f1, ChangeKind.Delete),
- (f2, ChangeKind.Delete),
- (f3, ChangeKind.Delete),
+ new(subdir, ChangeKind.Delete),
+ new(f1, ChangeKind.Delete),
+ new(f2, ChangeKind.Delete),
+ new(f3, ChangeKind.Delete),
],
usePolling,
() => Directory.Delete(subdir, recursive: true));
diff --git a/test/dotnet-watch.Tests/HotReload/HotReloadDotNetWatcherTests.cs b/test/dotnet-watch.Tests/HotReload/HotReloadDotNetWatcherTests.cs
new file mode 100644
index 000000000000..0ed9ee279203
--- /dev/null
+++ b/test/dotnet-watch.Tests/HotReload/HotReloadDotNetWatcherTests.cs
@@ -0,0 +1,39 @@
+// 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.UnitTests;
+
+public class HotReloadDotNetWatcherTests
+{
+ [Theory]
+ [InlineData(new[] { ChangeKind.Update }, new[] { ChangeKind.Update })]
+ [InlineData(new[] { ChangeKind.Add }, new[] { ChangeKind.Add })]
+ [InlineData(new[] { ChangeKind.Delete }, new[] { ChangeKind.Delete })]
+
+ [InlineData(new[] { ChangeKind.Update, ChangeKind.Update }, new[] { ChangeKind.Update })]
+ [InlineData(new[] { ChangeKind.Update, ChangeKind.Delete }, new[] { ChangeKind.Delete })]
+ [InlineData(new[] { ChangeKind.Add, ChangeKind.Update }, new[] { ChangeKind.Add })]
+ [InlineData(new[] { ChangeKind.Add, ChangeKind.Delete }, new ChangeKind[] { })]
+ [InlineData(new[] { ChangeKind.Delete, ChangeKind.Add}, new[] { ChangeKind.Update })]
+ [InlineData(new[] { ChangeKind.Add, ChangeKind.Add }, new[] { ChangeKind.Add })]
+ [InlineData(new[] { ChangeKind.Delete, ChangeKind.Delete }, new[] { ChangeKind.Delete })]
+
+ [InlineData(new[] { ChangeKind.Add, ChangeKind.Delete, ChangeKind.Add, ChangeKind.Delete }, new ChangeKind[] { })]
+ [InlineData(new[] { ChangeKind.Update, ChangeKind.Delete, ChangeKind.Add, ChangeKind.Update }, new[] { ChangeKind.Update })]
+ [InlineData(new[] { ChangeKind.Update, ChangeKind.Delete, ChangeKind.Update, ChangeKind.Add, ChangeKind.Update }, new[] { ChangeKind.Update })]
+ [InlineData(new[] { ChangeKind.Add, ChangeKind.Delete, ChangeKind.Delete }, new ChangeKind[] { })]
+ [InlineData(new[] { ChangeKind.Add, ChangeKind.Add, ChangeKind.Delete }, new ChangeKind[] { })]
+ [InlineData(new[] { ChangeKind.Add, ChangeKind.Update, ChangeKind.Delete }, new ChangeKind[] { })]
+ [InlineData(new[] { ChangeKind.Update, ChangeKind.Add, ChangeKind.Delete }, new[] { ChangeKind.Update })]
+
+ // File.WriteAllText on macOS may produce Update + Add.
+ [InlineData(new[] { ChangeKind.Update, ChangeKind.Add }, new[] { ChangeKind.Update })]
+
+ // The following case should not occur in practice:
+ [InlineData(new[] { ChangeKind.Delete, ChangeKind.Update }, new[] { ChangeKind.Delete })]
+ internal void NormalizeFileChanges(ChangeKind[] changes, ChangeKind[] expected)
+ {
+ var normalized = HotReloadDotNetWatcher.NormalizePathChanges(changes.Select(kind => new ChangedPath("a.html", kind)));
+ AssertEx.SequenceEqual(expected, normalized.Select(c => c.Kind));
+ }
+}
diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs
index d13f9b78a633..6e216892918d 100644
--- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs
+++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs
@@ -3,6 +3,7 @@
#nullable enable
+using System.Collections.Immutable;
using System.Runtime.CompilerServices;
namespace Microsoft.DotNet.Watch.UnitTests;
@@ -16,10 +17,34 @@ public enum TriggerEvent
WaitingForChanges,
}
- private record class RunningWatcher(Task Task, TestReporter Reporter, TestConsole Console, StrongBox ServiceHolder, CancellationTokenSource ShutdownSource)
+ private record class RunningWatcher(
+ RuntimeProcessLauncherTests Test,
+ HotReloadDotNetWatcher Watcher,
+ Task Task,
+ TestReporter Reporter,
+ TestConsole Console,
+ StrongBox ServiceHolder,
+ CancellationTokenSource ShutdownSource) : IAsyncDisposable
{
public TestRuntimeProcessLauncher? Service => ServiceHolder.Value;
+ public async ValueTask DisposeAsync()
+ {
+ if (!ShutdownSource.IsCancellationRequested)
+ {
+ Test.Log("Shutting down");
+ ShutdownSource.Cancel();
+ }
+
+ try
+ {
+ await Task;
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+
public TaskCompletionSource CreateCompletionSource()
{
var source = new TaskCompletionSource();
@@ -29,7 +54,7 @@ public TaskCompletionSource CreateCompletionSource()
}
private TestAsset CopyTestAsset(string assetName, params object[] testParameters)
- => TestAssets.CopyTestAsset("WatchAppMultiProc", identifier: string.Join(";", testParameters)).WithSource();
+ => TestAssets.CopyTestAsset(assetName, identifier: string.Join(";", testParameters)).WithSource();
private static async Task Launch(string projectPath, TestRuntimeProcessLauncher service, string workingDirectory, CancellationToken cancellationToken)
{
@@ -67,7 +92,7 @@ private static async Task Launch(string projectPath, TestRuntime
return await startOp(cancellationToken);
}
- private RunningWatcher StartWatcher(TestAsset testAsset, string[] args, string workingDirectory, string projectPath)
+ private RunningWatcher StartWatcher(TestAsset testAsset, string[] args, string workingDirectory, string projectPath, SemaphoreSlim? fileChangesCompleted = null)
{
var console = new TestConsole(Logger);
var reporter = new TestReporter(Logger);
@@ -90,22 +115,22 @@ private RunningWatcher StartWatcher(TestAsset testAsset, string[] args, string w
var watcher = Assert.IsType(program.CreateWatcher(factory));
- var shutdownToken = new CancellationTokenSource();
+ var shutdownSource = new CancellationTokenSource();
var watchTask = Task.Run(async () =>
{
try
{
- await watcher.WatchAsync(shutdownToken.Token);
+ await watcher.WatchAsync(shutdownSource.Token);
}
- catch (Exception e)
+ catch (Exception e) when (e is not OperationCanceledException)
{
- shutdownToken.Cancel();
+ shutdownSource.Cancel();
((IReporter)reporter).Error($"Unexpected exception {e}");
throw;
}
- });
+ }, shutdownSource.Token);
- return new RunningWatcher(watchTask, reporter, console, serviceHolder, shutdownToken);
+ return new RunningWatcher(this, watcher, watchTask, reporter, console, serviceHolder, shutdownSource);
}
[Theory]
@@ -127,7 +152,7 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger)
var libProject = Path.Combine(libDir, "Lib.csproj");
var libSource = Path.Combine(libDir, "Lib.cs");
- var w = StartWatcher(testAsset, ["--non-interactive"], workingDirectory, hostProject);
+ await using var w = StartWatcher(testAsset, ["--non-interactive"], workingDirectory, hostProject);
var launchCompletionA = w.CreateCompletionSource();
var launchCompletionB = w.CreateCompletionSource();
@@ -158,9 +183,6 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger)
var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges);
- var launchedProcessCount = 0;
- w.Reporter.RegisterAction(MessageDescriptor.LaunchedProcess, () => Interlocked.Increment(ref launchedProcessCount));
-
var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled);
var sessionStarted = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadSessionStarted);
var projectBaselinesUpdated = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectBaselinesUpdated);
@@ -189,19 +211,6 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger)
Log("Waiting for changed handled ...");
await changeHandled.WaitAsync(w.ShutdownSource.Token);
- // clean up:
- Log("Shutting down");
- w.ShutdownSource.Cancel();
- try
- {
- await w.Task;
- }
- catch (OperationCanceledException)
- {
- }
-
- Assert.Equal(6, launchedProcessCount);
-
// Hot Reload shared dependency - should update both service projects
async Task MakeValidDependencyChange()
{
@@ -280,8 +289,6 @@ async Task MakeRudeEditChange()
await hasUpdateSource.Task;
Assert.True(hasUpdateSource.Task.IsCompletedSuccessfully);
-
- Assert.Equal(6, launchedProcessCount);
}
}
@@ -307,7 +314,7 @@ public async Task UpdateAppliedToNewProcesses(bool sharedOutput)
var libProject = Path.Combine(libDir, "Lib.csproj");
var libSource = Path.Combine(libDir, "Lib.cs");
- var w = StartWatcher(testAsset, ["--non-interactive"], workingDirectory, hostProject);
+ await using var w = StartWatcher(testAsset, ["--non-interactive"], workingDirectory, hostProject);
var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges);
var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled);
@@ -374,17 +381,6 @@ public static void Common()
Log("Waiting for updated output from B ...");
await hasUpdateB.WaitAsync(w.ShutdownSource.Token);
-
- // clean up:
- Log("Shutting down");
- w.ShutdownSource.Cancel();
- try
- {
- await w.Task;
- }
- catch (OperationCanceledException)
- {
- }
}
public enum UpdateLocation
@@ -407,7 +403,7 @@ public async Task HostRestart(UpdateLocation updateLocation)
var libProject = Path.Combine(testAsset.Path, "Lib2", "Lib2.csproj");
var lib = Path.Combine(testAsset.Path, "Lib2", "Lib2.cs");
- var w = StartWatcher(testAsset, args: [], workingDirectory, hostProject);
+ await using var w = StartWatcher(testAsset, args: [], workingDirectory, hostProject);
var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges);
var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled);
@@ -482,16 +478,6 @@ public static void Print()
Log("Waiting updated output from Host ...");
await hasUpdate.WaitAsync(w.ShutdownSource.Token);
-
- // clean up:
- w.ShutdownSource.Cancel();
- try
- {
- await w.Task;
- }
- catch (OperationCanceledException)
- {
- }
}
[Fact]
@@ -506,7 +492,7 @@ public async Task RudeEditInProjectWithoutRunningProcess()
var serviceSourceA2 = Path.Combine(serviceDirA, "A2.cs");
var serviceProjectA = Path.Combine(serviceDirA, "A.csproj");
- var w = StartWatcher(testAsset, ["--non-interactive"], workingDirectory, hostProject);
+ await using var w = StartWatcher(testAsset, ["--non-interactive"], workingDirectory, hostProject);
var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges);
@@ -537,15 +523,102 @@ public async Task RudeEditInProjectWithoutRunningProcess()
w.Reporter.ProcessOutput.Contains("verbose ⌚ Rude edits detected but do not affect any running process");
w.Reporter.ProcessOutput.Contains($"verbose ❌ {serviceSourceA2}(1,12): error ENC0003: Updating 'attribute' requires restarting the application.");
+ }
- // clean up:
- w.ShutdownSource.Cancel();
- try
+ public enum DirectoryKind
+ {
+ Ordinary,
+ Hidden,
+ Bin,
+ Obj,
+ }
+
+ [Theory]
+ [CombinatorialData]
+ public async Task IgnoredChange(bool isExisting, bool isIncluded, DirectoryKind directoryKind)
+ {
+ var testAsset = CopyTestAsset("WatchNoDepsApp", [isExisting, isIncluded, directoryKind]);
+
+ var workingDirectory = testAsset.Path;
+
+ var objDir = Path.Combine(workingDirectory, "obj", "Debug", ToolsetInfo.CurrentTargetFramework);
+ var binDir = Path.Combine(workingDirectory, "bin", "Debug", ToolsetInfo.CurrentTargetFramework);
+
+ var hiddenDir = Path.Combine(workingDirectory, "hidden");
+ Directory.CreateDirectory(hiddenDir);
+ File.SetAttributes(hiddenDir, FileAttributes.Hidden | FileAttributes.Directory);
+
+ var extension = isIncluded ? ".cs" : ".txt";
+ var dir = directoryKind switch
{
- await w.Task;
+ DirectoryKind.Bin => binDir,
+ DirectoryKind.Obj => objDir,
+ DirectoryKind.Hidden => hiddenDir,
+ _ => workingDirectory,
+ };
+
+ Directory.CreateDirectory(dir);
+
+ var path = Path.Combine(dir, "File" + extension);
+
+ if (isExisting)
+ {
+ File.WriteAllText(path, "class C { int F() => 1; }");
+
+ if (isIncluded && directoryKind is DirectoryKind.Bin or DirectoryKind.Obj)
+ {
+ var project = Path.Combine(workingDirectory, "WatchNoDepsApp.csproj");
+ File.WriteAllText(project, File.ReadAllText(project).Replace(
+ "",
+ $"""
+
+ """));
+ }
}
- catch (OperationCanceledException)
+
+ await using var w = StartWatcher(testAsset, ["--no-exit"], workingDirectory, workingDirectory);
+
+ var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges);
+ var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled);
+ var ignoringChangeInHiddenDirectory = w.Reporter.RegisterSemaphore(MessageDescriptor.IgnoringChangeInHiddenDirectory);
+ var ignoringChangeInOutputDirectory = w.Reporter.RegisterSemaphore(MessageDescriptor.IgnoringChangeInOutputDirectory);
+ var fileAdditionTriggeredReEvaluation = w.Reporter.RegisterSemaphore(MessageDescriptor.FileAdditionTriggeredReEvaluation);
+ var noHotReloadChangesToApply = w.Reporter.RegisterSemaphore(MessageDescriptor.NoHotReloadChangesToApply);
+
+ Log("Waiting for changes...");
+ await waitingForChanges.WaitAsync(w.ShutdownSource.Token);
+
+ UpdateSourceFile(path, "class C { int F() => 2; }");
+
+ switch ((isExisting, isIncluded, directoryKind))
{
+ case (isExisting: true, isIncluded: true, directoryKind: _):
+ Log("Waiting for changed handled ...");
+ await changeHandled.WaitAsync(w.ShutdownSource.Token);
+ break;
+
+ case (isExisting: true, isIncluded: false, directoryKind: DirectoryKind.Ordinary):
+ Log("Waiting for no hot reload changes to apply ...");
+ await noHotReloadChangesToApply.WaitAsync(w.ShutdownSource.Token);
+ break;
+
+ case (isExisting: false, isIncluded: _, directoryKind: DirectoryKind.Ordinary):
+ Log("Waiting for file addition re-evalutation ...");
+ await fileAdditionTriggeredReEvaluation.WaitAsync(w.ShutdownSource.Token);
+ break;
+
+ case (isExisting: _, isIncluded: _, directoryKind: DirectoryKind.Hidden):
+ Log("Waiting for ignored change in hidden dir ...");
+ await ignoringChangeInHiddenDirectory.WaitAsync(w.ShutdownSource.Token);
+ break;
+
+ case (isExisting: _, isIncluded: _, directoryKind: DirectoryKind.Bin or DirectoryKind.Obj):
+ Log("Waiting for ignored change in output dir ...");
+ await ignoringChangeInOutputDirectory.WaitAsync(w.ShutdownSource.Token);
+ break;
+
+ default:
+ throw new InvalidOperationException();
}
}
}
diff --git a/test/dotnet-watch.Tests/Utilities/AssertEx.cs b/test/dotnet-watch.Tests/Utilities/AssertEx.cs
index 6ed30d98987e..ca4e251fa288 100644
--- a/test/dotnet-watch.Tests/Utilities/AssertEx.cs
+++ b/test/dotnet-watch.Tests/Utilities/AssertEx.cs
@@ -157,7 +157,7 @@ public static void SequenceEqual(
Assert.NotNull(actual);
}
- if (!expected.SequenceEqual(actual, comparer))
+ if (!expected.SequenceEqual(actual, comparer ?? EqualityComparer.Default))
{
Fail(GetAssertMessage(expected, actual, message, itemInspector, itemSeparator));
}
diff --git a/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs b/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs
index 4650141560af..3c561b86c0ce 100644
--- a/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs
+++ b/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs
@@ -28,16 +28,32 @@ public void Log(string message)
public void UpdateSourceFile(string path, string text)
{
- File.WriteAllText(path, text, Encoding.UTF8);
+ WriteAllText(path, text);
Log($"File '{path}' updated ({HotReloadDotNetWatcher.FormatTimestamp(File.GetLastWriteTimeUtc(path))}).");
}
public void UpdateSourceFile(string path, Func contentTransform)
{
- File.WriteAllText(path, contentTransform(File.ReadAllText(path, Encoding.UTF8)), Encoding.UTF8);
+ WriteAllText(path, contentTransform(File.ReadAllText(path, Encoding.UTF8)));
Log($"File '{path}' updated.");
}
+ ///
+ /// Replacement for , which fails to write to hidden file
+ ///
+ public static void WriteAllText(string path, string text)
+ {
+ using var stream = File.Open(path, FileMode.OpenOrCreate);
+
+ using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true))
+ {
+ writer.Write(text);
+ }
+
+ // truncate the rest of the file content:
+ stream.SetLength(stream.Position);
+ }
+
public void UpdateSourceFile(string path)
=> UpdateSourceFile(path, content => content);