From c1bcc13dc7b1f9350d65666e8a9e5acb428121c4 Mon Sep 17 00:00:00 2001 From: tmat Date: Mon, 25 Nov 2024 20:38:35 -0800 Subject: [PATCH] Improve file change post-processing --- src/BuiltInTools/dotnet-watch/FileItem.cs | 5 +- .../HotReload/CompilationHandler.cs | 2 +- .../dotnet-watch/HotReloadDotNetWatcher.cs | 238 ++++++++++++------ .../dotnet-watch/Internal/FileWatcher.cs | 45 ++-- .../Internal/FileWatcher/ChangeKind.cs | 4 +- .../FileWatcher/EventBasedDirectoryWatcher.cs | 4 +- .../Internal/FileWatcher/IDirectoryWatcher.cs | 2 +- .../FileWatcher/PollingDirectoryWatcher.cs | 4 +- .../dotnet-watch/Internal/IReporter.cs | 4 + .../dotnet-watch/Utilities/PathUtilities.cs | 45 ++++ .../Utilities/ProjectGraphNodeExtensions.cs | 6 + .../Directory.Build.targets | 8 + test/dotnet-watch.Tests/FileWatcherTests.cs | 125 ++++----- .../HotReload/HotReloadDotNetWatcherTests.cs | 39 +++ .../HotReload/RuntimeProcessLauncherTests.cs | 187 +++++++++----- test/dotnet-watch.Tests/Utilities/AssertEx.cs | 2 +- .../Watch/Utilities/DotNetWatchTestBase.cs | 20 +- 17 files changed, 516 insertions(+), 224 deletions(-) create mode 100644 src/BuiltInTools/dotnet-watch/Utilities/PathUtilities.cs create mode 100644 test/dotnet-watch.Tests/Directory.Build.targets create mode 100644 test/dotnet-watch.Tests/HotReload/HotReloadDotNetWatcherTests.cs 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);