From b60190cb8b631b28707bdb5b52e5aefe681d9bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Matou=C5=A1ek?= Date: Sat, 9 Nov 2024 16:29:00 -0800 Subject: [PATCH] Simplify file watching (#44603) --- .../dotnet-watch/DotNetWatcher.cs | 12 +- .../dotnet-watch/EnvironmentOptions.cs | 6 +- .../dotnet-watch/EnvironmentVariables.cs | 1 + .../dotnet-watch/Filters/BuildEvaluator.cs | 7 +- .../HotReload/DefaultDeltaApplier.cs | 5 + .../dotnet-watch/HotReloadDotNetWatcher.cs | 203 +++++++++++++++--- .../dotnet-watch/Internal/FileWatcher.cs | 62 +++--- ...tcher.cs => EventBasedDirectoryWatcher.cs} | 54 ++--- .../FileWatcher/FileWatcherFactory.cs | 8 +- ...eSystemWatcher.cs => IDirectoryWatcher.cs} | 4 +- ...eWatcher.cs => PollingDirectoryWatcher.cs} | 81 +++---- .../Internal/HotReloadFileSetWatcher.cs | 178 --------------- .../Internal/MsBuildFileSetFactory.cs | 2 +- .../Properties/launchSettings.json | 4 +- test/dotnet-watch.Tests/FileWatcherTests.cs | 4 +- .../HotReload/ApplyDeltaTests.cs | 21 +- .../HotReload/RuntimeProcessLauncherTests.cs | 56 +++-- .../Utilities/AwaitableProcess.cs | 96 ++++----- .../Utilities/TestOptions.cs | 20 +- .../Watch/DotNetWatcherTests.cs | 2 +- test/dotnet-watch.Tests/Watch/ProgramTests.cs | 15 +- .../Watch/Utilities/DotNetWatchTestBase.cs | 7 +- .../Watch/Utilities/WatchableApp.cs | 14 +- 23 files changed, 423 insertions(+), 439 deletions(-) rename src/BuiltInTools/dotnet-watch/Internal/FileWatcher/{DotnetFileWatcher.cs => EventBasedDirectoryWatcher.cs} (80%) rename src/BuiltInTools/dotnet-watch/Internal/FileWatcher/{IFileSystemWatcher.cs => IDirectoryWatcher.cs} (79%) rename src/BuiltInTools/dotnet-watch/Internal/FileWatcher/{PollingFileWatcher.cs => PollingDirectoryWatcher.cs} (79%) delete mode 100644 src/BuiltInTools/dotnet-watch/Internal/HotReloadFileSetWatcher.cs diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs index 9c4ab9f433c7..0e9715ac7a3c 100644 --- a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs @@ -80,7 +80,9 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke using var currentRunCancellationSource = new CancellationTokenSource(); using var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, currentRunCancellationSource.Token); - using var fileSetWatcher = new FileWatcher(evaluationResult.Files, Context.Reporter); + using var fileSetWatcher = new FileWatcher(Context.Reporter); + + fileSetWatcher.WatchContainingDirectories(evaluationResult.Files.Keys); var processTask = ProcessRunner.RunAsync(processSpec, Context.Reporter, isUserApplication: true, launchResult: null, combinedCancellationSource.Token); @@ -89,7 +91,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke while (true) { - fileSetTask = fileSetWatcher.GetChangedFileAsync(startedWatching: null, combinedCancellationSource.Token); + fileSetTask = fileSetWatcher.WaitForFileChangeAsync(evaluationResult.Files, startedWatching: null, combinedCancellationSource.Token); finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task); if (staticFileHandler != null && finishedTask == fileSetTask && fileSetTask.Result.HasValue) @@ -119,9 +121,11 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke { // Process exited. Redo evalulation buildEvaluator.RequiresRevaluation = true; + // Now wait for a file to change before restarting process - changedFile = await fileSetWatcher.GetChangedFileAsync( - () => Context.Reporter.Report(MessageDescriptor.WaitingForFileChangeBeforeRestarting), + changedFile = await fileSetWatcher.WaitForFileChangeAsync( + evaluationResult.Files, + startedWatching: () => Context.Reporter.Report(MessageDescriptor.WaitingForFileChangeBeforeRestarting), shutdownCancellationToken); } else diff --git a/src/BuiltInTools/dotnet-watch/EnvironmentOptions.cs b/src/BuiltInTools/dotnet-watch/EnvironmentOptions.cs index a9488af4feea..22cbc3ae2e3d 100644 --- a/src/BuiltInTools/dotnet-watch/EnvironmentOptions.cs +++ b/src/BuiltInTools/dotnet-watch/EnvironmentOptions.cs @@ -34,7 +34,8 @@ internal sealed record EnvironmentOptions( bool SuppressLaunchBrowser = false, bool SuppressBrowserRefresh = false, bool SuppressEmojis = false, - TestFlags TestFlags = TestFlags.None) + TestFlags TestFlags = TestFlags.None, + string TestOutput = "") { public static EnvironmentOptions FromEnvironment() => new ( @@ -46,7 +47,8 @@ internal sealed record EnvironmentOptions( SuppressLaunchBrowser: EnvironmentVariables.SuppressLaunchBrowser, SuppressBrowserRefresh: EnvironmentVariables.SuppressBrowserRefresh, SuppressEmojis: EnvironmentVariables.SuppressEmojis, - TestFlags: EnvironmentVariables.TestFlags + TestFlags: EnvironmentVariables.TestFlags, + TestOutput: EnvironmentVariables.TestOutputDir ); public bool RunningAsTest { get => (TestFlags & TestFlags.RunningAsTest) != TestFlags.None; } diff --git a/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs b/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs index f0ee60534903..021e96f763bf 100644 --- a/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs +++ b/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs @@ -36,6 +36,7 @@ public static partial class Names public static bool SuppressBrowserRefresh => ReadBool("DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH"); public static TestFlags TestFlags => Environment.GetEnvironmentVariable("__DOTNET_WATCH_TEST_FLAGS") is { } value ? Enum.Parse(value) : TestFlags.None; + public static string TestOutputDir => Environment.GetEnvironmentVariable("__DOTNET_WATCH_TEST_OUTPUT_DIR") ?? ""; public static string? AutoReloadWSHostName => Environment.GetEnvironmentVariable("DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME"); public static string? BrowserPath => Environment.GetEnvironmentVariable("DOTNET_WATCH_BROWSER_PATH"); diff --git a/src/BuiltInTools/dotnet-watch/Filters/BuildEvaluator.cs b/src/BuiltInTools/dotnet-watch/Filters/BuildEvaluator.cs index 3ff411fe0145..24a1713fa14d 100644 --- a/src/BuiltInTools/dotnet-watch/Filters/BuildEvaluator.cs +++ b/src/BuiltInTools/dotnet-watch/Filters/BuildEvaluator.cs @@ -84,8 +84,11 @@ private async ValueTask CreateEvaluationResult(CancellationTok return result; } - context.Reporter.Warn("Fix the error to continue or press Ctrl+C to exit."); - await FileWatcher.WaitForFileChangeAsync(rootProjectFileSetFactory.RootProjectFile, context.Reporter, cancellationToken); + await FileWatcher.WaitForFileChangeAsync( + rootProjectFileSetFactory.RootProjectFile, + context.Reporter, + startedWatching: () => context.Reporter.Warn("Fix the error to continue or press Ctrl+C to exit."), + cancellationToken); } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs index 80487deeb808..aa9ef087e158 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs @@ -41,6 +41,11 @@ async Task> ConnectAsync() Reporter.Verbose($"Capabilities: '{capabilities}'"); return capabilities.Split(' ').ToImmutableArray(); } + catch (EndOfStreamException) + { + // process terminated before capabilities sent: + return []; + } catch (Exception e) when (e is not OperationCanceledException) { // pipe might throw another exception when forcibly closed on process termination: diff --git a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs index 14d4fe3bf3af..06748f0f0aa7 100644 --- a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; using System.Diagnostics; using Microsoft.DotNet.Watcher.Internal; using Microsoft.DotNet.Watcher.Tools; @@ -10,6 +11,8 @@ namespace Microsoft.DotNet.Watcher { internal sealed class HotReloadDotNetWatcher : Watcher { + private static readonly DateTime s_fileNotExistFileTime = DateTime.FromFileTime(0); + private readonly IConsole _console; private readonly IRuntimeProcessLauncherFactory? _runtimeProcessLauncherFactory; private readonly RestartPrompt? _rudeEditRestartPrompt; @@ -57,6 +60,8 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke Context.Reporter.Output(hotReloadEnabledMessage, emoji: "🔥"); } + using var fileWatcher = new FileWatcher(Context.Reporter); + for (var iteration = 0; !shutdownCancellationToken.IsCancellationRequested; iteration++) { Interlocked.Exchange(ref forceRestartCancellationSource, new CancellationTokenSource())?.Dispose(); @@ -68,12 +73,12 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke var iterationCancellationToken = iterationCancellationSource.Token; var waitForFileChangeBeforeRestarting = true; - HotReloadFileSetWatcher? fileSetWatcher = null; EvaluationResult? evaluationResult = null; RunningProject? rootRunningProject = null; - Task? fileSetWatcherTask = null; + Task>? fileWatcherTask = null; IRuntimeProcessLauncher? runtimeProcessLauncher = null; CompilationHandler? compilationHandler = null; + Action? fileChangedCallback = null; try { @@ -171,30 +176,56 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke return; } - fileSetWatcher = new HotReloadFileSetWatcher(evaluationResult.Files, buildCompletionTime, Context.Reporter, Context.EnvironmentOptions.TestFlags); + fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys); + + var changedFilesAccumulator = ImmutableList.Empty; + + void FileChangedCallback(string path, ChangeKind kind) + { + if (TryGetChangedFile(evaluationResult.Files, buildCompletionTime, path, kind) is { } changedFile) + { + ImmutableInterlocked.Update(ref changedFilesAccumulator, changedFiles => changedFiles.Add(changedFile)); + } + else + { + Context.Reporter.Verbose($"Change ignored: {kind} '{path}'."); + } + } + + fileChangedCallback = FileChangedCallback; + fileWatcher.OnFileChange += fileChangedCallback; + ReportWatchingForChanges(); // Hot Reload loop - exits when the root process needs to be restarted. while (true) { - fileSetWatcherTask = fileSetWatcher.GetChangedFilesAsync(iterationCancellationToken); - - var finishedTask = await Task.WhenAny(rootRunningProject.RunningProcess, fileSetWatcherTask).WaitAsync(iterationCancellationToken); - if (finishedTask == rootRunningProject.RunningProcess) + try { - // Cancel the iteration, but wait for a file change before starting a new one. + // 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); + + // Process exited: cancel the iteration, but wait for a file change before starting a new one + waitForFileChangeBeforeRestarting = true; iterationCancellationSource.Cancel(); break; } - - // File watcher returns null when canceled: - if (fileSetWatcherTask.Result is not { } changedFiles) + catch (TimeoutException) + { + // check for changed files + } + catch (OperationCanceledException) { Debug.Assert(iterationCancellationToken.IsCancellationRequested); waitForFileChangeBeforeRestarting = false; break; } - ReportFileChanges(changedFiles); + var changedFiles = Interlocked.Exchange(ref changedFilesAccumulator, []); + if (changedFiles is []) + { + continue; + } // 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.). @@ -205,6 +236,9 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke evaluationResult = await EvaluateRootProjectAsync(iterationCancellationToken); + // additional directories may have been added: + fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys); + await compilationHandler.Workspace.UpdateProjectConeAsync(RootFileSetFactory.RootProjectFile, iterationCancellationToken); if (shutdownCancellationToken.IsCancellationRequested) @@ -214,20 +248,15 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke } // update files in the change set with new evaluation info: - for (int i = 0; i < changedFiles.Length; i++) - { - if (evaluationResult.Files.TryGetValue(changedFiles[i].Item.FilePath, out var evaluatedFile)) - { - changedFiles[i] = changedFiles[i] with { Item = evaluatedFile }; - } - } + changedFiles = changedFiles.Select(f => evaluationResult.Files.TryGetValue(f.Item.FilePath, out var evaluatedFile) ? f with { Item = evaluatedFile } : f) + .ToImmutableList(); ReportFileChanges(changedFiles); - - fileSetWatcher = new HotReloadFileSetWatcher(evaluationResult.Files, buildCompletionTime, Context.Reporter, Context.EnvironmentOptions.TestFlags); } else { + ReportFileChanges(changedFiles); + // update the workspace to reflect changes in the file content: await compilationHandler.Workspace.UpdateFileContentAsync(changedFiles, iterationCancellationToken); } @@ -313,7 +342,8 @@ await Task.WhenAll( .WaitAsync(shutdownCancellationToken); // Update build completion time, so that file changes caused by the rebuild do not affect our file watcher: - fileSetWatcher.UpdateBuildCompletionTime(DateTime.UtcNow); + buildCompletionTime = DateTime.UtcNow; + changedFilesAccumulator = []; // Restart session to capture new baseline that reflects the changes to the restarted projects. await compilationHandler.RestartSessionAsync(projectsToBeRebuilt, iterationCancellationToken); @@ -326,6 +356,12 @@ await Task.WhenAll( } finally { + // stop watching file changes: + if (fileChangedCallback != null) + { + fileWatcher.OnFileChange -= fileChangedCallback; + } + if (runtimeProcessLauncher != null) { // Request cleanup of all processes created by the launcher before we terminate the root process. @@ -347,7 +383,7 @@ await Task.WhenAll( try { // Wait for the root process to exit. - await Task.WhenAll(new[] { (Task?)rootRunningProject?.RunningProcess, fileSetWatcherTask }.Where(t => t != null)!); + await Task.WhenAll(new[] { (Task?)rootRunningProject?.RunningProcess, fileWatcherTask }.Where(t => t != null)!); } catch (OperationCanceledException) when (!shutdownCancellationToken.IsCancellationRequested) { @@ -355,7 +391,7 @@ await Task.WhenAll( } finally { - fileSetWatcherTask = null; + fileWatcherTask = null; if (runtimeProcessLauncher != null) { @@ -364,22 +400,120 @@ await Task.WhenAll( rootRunningProject?.Dispose(); - if (evaluationResult != null && - waitForFileChangeBeforeRestarting && + if (waitForFileChangeBeforeRestarting && !shutdownCancellationToken.IsCancellationRequested && !forceRestartCancellationSource.IsCancellationRequested) { - fileSetWatcher ??= new HotReloadFileSetWatcher(evaluationResult.Files, DateTime.MinValue, Context.Reporter, Context.EnvironmentOptions.TestFlags); - Context.Reporter.Report(MessageDescriptor.WaitingForFileChangeBeforeRestarting); - using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token); - await fileSetWatcher.GetChangedFilesAsync(shutdownOrForcedRestartSource.Token, forceWaitForNewUpdate: true); + await WaitForFileChangeBeforeRestarting(fileWatcher, evaluationResult, shutdownOrForcedRestartSource.Token); } + } + } + } + } + + private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatcher, EvaluationResult? evaluationResult, CancellationToken cancellationToken) + { + if (evaluationResult != null) + { + if (!fileWatcher.WatchingDirectories) + { + fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys); + } + + _ = await fileWatcher.WaitForFileChangeAsync( + evaluationResult.Files, + startedWatching: () => Context.Reporter.Report(MessageDescriptor.WaitingForFileChangeBeforeRestarting), + cancellationToken); + } + else + { + // evaluation cancelled - watch for any changes in the directory containing the root project: + fileWatcher.WatchContainingDirectories([RootFileSetFactory.RootProjectFile]); + + _ = await fileWatcher.WaitForFileChangeAsync( + (path, change) => new ChangedFile(new FileItem() { FilePath = path }, change), + startedWatching: () => Context.Reporter.Report(MessageDescriptor.WaitingForFileChangeBeforeRestarting), + cancellationToken); + } + } - fileSetWatcher?.Dispose(); + private ChangedFile? TryGetChangedFile(IReadOnlyDictionary fileSet, DateTime buildCompletionTime, string path, ChangeKind kind) + { + // only handle file changes: + if (Directory.Exists(path)) + { + return null; + } + + if (kind != ChangeKind.Delete) + { + try + { + // Do not report changes to files that happened during build: + var creationTime = File.GetCreationTimeUtc(path); + var writeTime = File.GetLastWriteTimeUtc(path); + + 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 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; } } + + if (kind == ChangeKind.Delete) + { + Context.Reporter.Verbose($"File '{path}' deleted after {FormatTimestamp(buildCompletionTime)}."); + } + + if (fileSet.TryGetValue(path, out var fileItem)) + { + // 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); + } + + if (kind == ChangeKind.Add) + { + return new ChangedFile(new FileItem { FilePath = path }, kind); + } + + return null; + } + + internal static string FormatTimestamp(DateTime time) + => time.ToString("HH:mm:ss.fffffff"); + + private void ReportWatchingForChanges() + { + var waitingForChanges = MessageDescriptor.WaitingForChanges; + if (Context.EnvironmentOptions.TestFlags.HasFlag(TestFlags.ElevateWaitingForChangesMessageSeverity)) + { + waitingForChanges = waitingForChanges with { Severity = MessageSeverity.Output }; + } + + Context.Reporter.Report(waitingForChanges); } private void ReportFileChanges(IReadOnlyList changedFiles) @@ -434,8 +568,11 @@ private async ValueTask EvaluateRootProjectAsync(CancellationT return result; } - Context.Reporter.Report(MessageDescriptor.FixBuildError); - await FileWatcher.WaitForFileChangeAsync(RootFileSetFactory.RootProjectFile, Context.Reporter, cancellationToken); + await FileWatcher.WaitForFileChangeAsync( + RootFileSetFactory.RootProjectFile, + Context.Reporter, + startedWatching: () => Context.Reporter.Report(MessageDescriptor.FixBuildError), + cancellationToken); } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs index 3a339bf63fb1..3b9f61208eff 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs @@ -5,9 +5,10 @@ namespace Microsoft.DotNet.Watcher.Internal { - internal sealed class FileWatcher(IReadOnlyDictionary fileSet, IReporter reporter) : IDisposable + internal sealed class FileWatcher(IReporter reporter) : IDisposable { - private readonly Dictionary _watchers = []; + // Directory watcher for each watched directory + private readonly Dictionary _watchers = []; private bool _disposed; public event Action? OnFileChange; @@ -29,13 +30,19 @@ public void Dispose() } } - public void StartWatching() + public bool WatchingDirectories + => _watchers.Count > 0; + + public void WatchContainingDirectories(IEnumerable filePaths) + => WatchDirectories(filePaths.Select(path => Path.GetDirectoryName(path)!)); + + public void WatchDirectories(IEnumerable directories) { - EnsureNotDisposed(); + ObjectDisposedException.ThrowIf(_disposed, this); - foreach (var (filePath, _) in fileSet) + foreach (var dir in directories) { - var directory = EnsureTrailingSlash(Path.GetDirectoryName(filePath)!); + var directory = EnsureTrailingSlash(dir); var alreadyWatched = _watchers .Where(d => directory.StartsWith(d.Key)) @@ -67,9 +74,9 @@ public void StartWatching() private void WatcherErrorHandler(object? sender, Exception error) { - if (sender is IFileSystemWatcher watcher) + if (sender is IDirectoryWatcher watcher) { - reporter.Warn($"The file watcher observing '{watcher.BasePath}' encountered an error: {error.Message}"); + reporter.Warn($"The file watcher observing '{watcher.WatchedDirectory}' encountered an error: {error.Message}"); } } @@ -90,29 +97,25 @@ private void DisposeWatcher(string directory) watcher.Dispose(); } - private void EnsureNotDisposed() - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(FileWatcher)); - } - } - private static string EnsureTrailingSlash(string path) => (path is [.., var last] && last != Path.DirectorySeparatorChar) ? path + Path.DirectorySeparatorChar : path; - public async Task GetChangedFileAsync(Action? startedWatching, CancellationToken cancellationToken) - { - StartWatching(); + public Task WaitForFileChangeAsync(IReadOnlyDictionary fileSet, Action? startedWatching, CancellationToken cancellationToken) + => WaitForFileChangeAsync( + changeFilter: (path, kind) => fileSet.TryGetValue(path, out var fileItem) ? new ChangedFile(fileItem, kind) : null, + startedWatching, + cancellationToken); + public async Task WaitForFileChangeAsync(Func changeFilter, Action? startedWatching, CancellationToken cancellationToken) + { var fileChangedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); cancellationToken.Register(() => fileChangedSource.TrySetResult(null)); void FileChangedCallback(string path, ChangeKind kind) { - if (fileSet.TryGetValue(path, out var fileItem)) + if (changeFilter(path, kind) is { } changedFile) { - fileChangedSource.TrySetResult(new ChangedFile(fileItem, kind)); + fileChangedSource.TrySetResult(changedFile); } } @@ -132,14 +135,21 @@ void FileChangedCallback(string path, ChangeKind kind) return changedFile; } - public static async ValueTask WaitForFileChangeAsync(string path, IReporter reporter, CancellationToken cancellationToken) + public static async ValueTask WaitForFileChangeAsync(string filePath, IReporter reporter, Action? startedWatching, CancellationToken cancellationToken) { - var fileSet = new Dictionary() { { path, new FileItem { FilePath = path } } }; + using var watcher = new FileWatcher(reporter); - using var watcher = new FileWatcher(fileSet, reporter); - await watcher.GetChangedFileAsync(startedWatching: null, cancellationToken); + watcher.WatchDirectories([Path.GetDirectoryName(filePath)!]); - reporter.Output($"File changed: {path}"); + var fileChange = await watcher.WaitForFileChangeAsync( + changeFilter: (path, kind) => path == filePath ? new ChangedFile(new FileItem { FilePath = path }, kind) : null, + startedWatching, + cancellationToken); + + if (fileChange != null) + { + reporter.Output($"File changed: {filePath}"); + } } } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/DotnetFileWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs similarity index 80% rename from src/BuiltInTools/dotnet-watch/Internal/FileWatcher/DotnetFileWatcher.cs rename to src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs index 7040fe1a0763..9c94c7b49b81 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/DotnetFileWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs @@ -6,44 +6,32 @@ namespace Microsoft.DotNet.Watcher.Internal { - internal class DotnetFileWatcher : IFileSystemWatcher + internal sealed class EventBasedDirectoryWatcher : IDirectoryWatcher { - internal Action? Logger { get; set; } + public event EventHandler<(string filePath, ChangeKind kind)>? OnFileChange; - private volatile bool _disposed; + public event EventHandler? OnError; + + public string WatchedDirectory { get; } - private readonly Func _watcherFactory; + internal Action? Logger { get; set; } + + private volatile bool _disposed; private FileSystemWatcher? _fileSystemWatcher; private readonly object _createLock = new(); - public DotnetFileWatcher(string watchedDirectory) - : this(watchedDirectory, DefaultWatcherFactory) + internal EventBasedDirectoryWatcher(string watchedDirectory) { - } - - internal DotnetFileWatcher(string watchedDirectory, Func fileSystemWatcherFactory) - { - Ensure.NotNull(fileSystemWatcherFactory, nameof(fileSystemWatcherFactory)); - Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory)); - - BasePath = watchedDirectory; - _watcherFactory = fileSystemWatcherFactory; + WatchedDirectory = watchedDirectory; CreateFileSystemWatcher(); } - public event EventHandler<(string filePath, ChangeKind kind)>? OnFileChange; - - public event EventHandler? OnError; - - public string BasePath { get; } - - private static FileSystemWatcher DefaultWatcherFactory(string watchedDirectory) + public void Dispose() { - Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory)); - - return new FileSystemWatcher(watchedDirectory); + _disposed = true; + DisposeInnerWatcher(); } private void WatcherErrorHandler(object sender, ErrorEventArgs e) @@ -62,7 +50,7 @@ private void WatcherErrorHandler(object sender, ErrorEventArgs e) // Win32Exception may be triggered when setting EnableRaisingEvents on a file system type // that is not supported, such as a network share. Don't attempt to recreate the watcher // in this case as it will cause a StackOverflowException - if (!(exception is Win32Exception)) + if (exception is not Win32Exception) { // Recreate the watcher if it is a recoverable error. CreateFileSystemWatcher(); @@ -147,8 +135,10 @@ private void CreateFileSystemWatcher() DisposeInnerWatcher(); } - _fileSystemWatcher = _watcherFactory(BasePath); - _fileSystemWatcher.IncludeSubdirectories = true; + _fileSystemWatcher = new FileSystemWatcher(WatchedDirectory) + { + IncludeSubdirectories = true + }; _fileSystemWatcher.Created += WatcherAddedHandler; _fileSystemWatcher.Deleted += WatcherDeletedHandler; @@ -162,7 +152,7 @@ private void CreateFileSystemWatcher() private void DisposeInnerWatcher() { - if ( _fileSystemWatcher != null ) + if (_fileSystemWatcher != null) { _fileSystemWatcher.EnableRaisingEvents = false; @@ -181,11 +171,5 @@ public bool EnableRaisingEvents get => _fileSystemWatcher!.EnableRaisingEvents; set => _fileSystemWatcher!.EnableRaisingEvents = value; } - - public void Dispose() - { - _disposed = true; - DisposeInnerWatcher(); - } } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/FileWatcherFactory.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/FileWatcherFactory.cs index b05242d80ecd..c5431d1f100d 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/FileWatcherFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/FileWatcherFactory.cs @@ -5,14 +5,14 @@ namespace Microsoft.DotNet.Watcher.Internal { internal static class FileWatcherFactory { - public static IFileSystemWatcher CreateWatcher(string watchedDirectory) + public static IDirectoryWatcher CreateWatcher(string watchedDirectory) => CreateWatcher(watchedDirectory, EnvironmentVariables.IsPollingEnabled); - public static IFileSystemWatcher CreateWatcher(string watchedDirectory, bool usePollingWatcher) + public static IDirectoryWatcher CreateWatcher(string watchedDirectory, bool usePollingWatcher) { return usePollingWatcher ? - new PollingFileWatcher(watchedDirectory) : - new DotnetFileWatcher(watchedDirectory); + new PollingDirectoryWatcher(watchedDirectory) : + new EventBasedDirectoryWatcher(watchedDirectory); } } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IFileSystemWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs similarity index 79% rename from src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IFileSystemWatcher.cs rename to src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs index ebdef49913ff..4cd187cb6fe0 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IFileSystemWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs @@ -3,13 +3,13 @@ namespace Microsoft.DotNet.Watcher.Internal { - internal interface IFileSystemWatcher : IDisposable + internal interface IDirectoryWatcher : IDisposable { event EventHandler<(string filePath, ChangeKind kind)> OnFileChange; event EventHandler OnError; - string BasePath { get; } + string WatchedDirectory { get; } bool EnableRaisingEvents { get; set; } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingFileWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs similarity index 79% rename from src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingFileWatcher.cs rename to src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs index 2df2004ee84b..df49f214adce 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingFileWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs @@ -6,33 +6,41 @@ namespace Microsoft.DotNet.Watcher.Internal { - internal class PollingFileWatcher : IFileSystemWatcher + internal sealed class PollingDirectoryWatcher : IDirectoryWatcher { // The minimum interval to rerun the scan private static readonly TimeSpan _minRunInternal = TimeSpan.FromSeconds(.5); private readonly DirectoryInfo _watchedDirectory; - private Dictionary _knownEntities = new(); - private Dictionary _tempDictionary = new(); - private Dictionary _changes = new(); + private Dictionary _knownEntities = []; + private Dictionary _tempDictionary = []; + private readonly Dictionary _changes = []; private Thread _pollingThread; private bool _raiseEvents; - private bool _disposed; + private volatile bool _disposed; - public PollingFileWatcher(string watchedDirectory) + public event EventHandler<(string filePath, ChangeKind kind)>? OnFileChange; + +#pragma warning disable CS0067 // not used + public event EventHandler? OnError; +#pragma warning restore + + public string WatchedDirectory { get; } + + public PollingDirectoryWatcher(string watchedDirectory) { Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory)); _watchedDirectory = new DirectoryInfo(watchedDirectory); - BasePath = _watchedDirectory.FullName; + WatchedDirectory = _watchedDirectory.FullName; _pollingThread = new Thread(new ThreadStart(PollingLoop)) { IsBackground = true, - Name = nameof(PollingFileWatcher) + Name = nameof(PollingDirectoryWatcher) }; CreateKnownFilesSnapshot(); @@ -40,20 +48,18 @@ public PollingFileWatcher(string watchedDirectory) _pollingThread.Start(); } - public event EventHandler<(string filePath, ChangeKind kind)>? OnFileChange; - -#pragma warning disable CS0067 // not used - public event EventHandler? OnError; -#pragma warning restore - - public string BasePath { get; } + public void Dispose() + { + EnableRaisingEvents = false; + _disposed = true; + } public bool EnableRaisingEvents { get => _raiseEvents; set { - EnsureNotDisposed(); + ObjectDisposedException.ThrowIf(_disposed, this); _raiseEvents = value; } } @@ -90,9 +96,9 @@ private void CreateKnownFilesSnapshot() { _knownEntities.Clear(); - ForeachEntityInDirectory(_watchedDirectory, f => + ForeachEntityInDirectory(_watchedDirectory, fileInfo => { - _knownEntities.Add(f.FullName, new FileMeta(f)); + _knownEntities.Add(fileInfo.FullName, new FileMeta(fileInfo, foundAgain: false)); }); } @@ -100,14 +106,14 @@ private void CheckForChangedFiles() { _changes.Clear(); - ForeachEntityInDirectory(_watchedDirectory, f => + ForeachEntityInDirectory(_watchedDirectory, fileInfo => { - var fullFilePath = f.FullName; + var fullFilePath = fileInfo.FullName; if (!_knownEntities.ContainsKey(fullFilePath)) { // New file or directory - RecordChange(f, ChangeKind.Add); + RecordChange(fileInfo, ChangeKind.Add); } else { @@ -116,10 +122,10 @@ private void CheckForChangedFiles() try { if (!fileMeta.FileInfo.Attributes.HasFlag(FileAttributes.Directory) && - fileMeta.FileInfo.LastWriteTime != f.LastWriteTime) + fileMeta.FileInfo.LastWriteTime != fileInfo.LastWriteTime) { // File changed - RecordChange(f, ChangeKind.Update); + RecordChange(fileInfo, ChangeKind.Update); } _knownEntities[fullFilePath] = new FileMeta(fileMeta.FileInfo, foundAgain: true); @@ -130,7 +136,7 @@ private void CheckForChangedFiles() } } - _tempDictionary.Add(f.FullName, new FileMeta(f)); + _tempDictionary.Add(fileInfo.FullName, new FileMeta(fileInfo, foundAgain: false)); }); foreach (var file in _knownEntities) @@ -211,31 +217,10 @@ private void NotifyChanges() } } - private void EnsureNotDisposed() - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(PollingFileWatcher)); - } - } - - public void Dispose() - { - EnableRaisingEvents = false; - _disposed = true; - } - - private struct FileMeta + private readonly struct FileMeta(FileSystemInfo fileInfo, bool foundAgain) { - public FileMeta(FileSystemInfo fileInfo, bool foundAgain = false) - { - FileInfo = fileInfo; - FoundAgain = foundAgain; - } - - public FileSystemInfo FileInfo; - - public bool FoundAgain; + public readonly FileSystemInfo FileInfo = fileInfo; + public readonly bool FoundAgain = foundAgain; } } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/HotReloadFileSetWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/HotReloadFileSetWatcher.cs deleted file mode 100644 index 255f7c1474f1..000000000000 --- a/src/BuiltInTools/dotnet-watch/Internal/HotReloadFileSetWatcher.cs +++ /dev/null @@ -1,178 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - - -using System.Collections.Concurrent; -using System.Diagnostics; -using Microsoft.Extensions.Tools.Internal; - -namespace Microsoft.DotNet.Watcher.Internal -{ - internal sealed class HotReloadFileSetWatcher(IReadOnlyDictionary fileSet, DateTime buildCompletionTime, IReporter reporter, TestFlags testFlags) : IDisposable - { - private static readonly TimeSpan s_debounceInterval = TimeSpan.FromMilliseconds(50); - private static readonly DateTime s_fileNotExistFileTime = DateTime.FromFileTime(0); - - private readonly FileWatcher _fileWatcher = new(fileSet, reporter); - private readonly object _changedFilesLock = new(); - private readonly ConcurrentDictionary _changedFiles = new(StringComparer.Ordinal); - - private TaskCompletionSource? _tcs; - private bool _initialized; - private bool _disposed; - - public void Dispose() - { - _disposed = true; - _fileWatcher.Dispose(); - } - - public void UpdateBuildCompletionTime(DateTime value) - { - lock (_changedFilesLock) - { - buildCompletionTime = value; - _changedFiles.Clear(); - } - } - - private void EnsureInitialized() - { - if (_initialized) - { - return; - } - - _initialized = true; - - _fileWatcher.StartWatching(); - _fileWatcher.OnFileChange += FileChangedCallback; - - var waitingForChanges = MessageDescriptor.WaitingForChanges; - if (testFlags.HasFlag(TestFlags.ElevateWaitingForChangesMessageSeverity)) - { - waitingForChanges = waitingForChanges with { Severity = MessageSeverity.Output }; - } - - reporter.Report(waitingForChanges); - - Task.Factory.StartNew(async () => - { - // Debounce / polling loop - while (!_disposed) - { - await Task.Delay(s_debounceInterval); - if (_changedFiles.IsEmpty) - { - continue; - } - - var tcs = Interlocked.Exchange(ref _tcs, null!); - if (tcs is null) - { - continue; - } - - ChangedFile[] changedFiles; - lock (_changedFilesLock) - { - changedFiles = _changedFiles.Values.ToArray(); - _changedFiles.Clear(); - } - - if (changedFiles is []) - { - continue; - } - - tcs.TrySetResult(changedFiles); - } - - }, default, TaskCreationOptions.LongRunning, TaskScheduler.Default); - - void FileChangedCallback(string path, ChangeKind kind) - { - // only handle file changes: - if (Directory.Exists(path)) - { - return; - } - - if (kind != ChangeKind.Delete) - { - try - { - // Do not report changes to files that happened during build: - var creationTime = File.GetCreationTimeUtc(path); - var writeTime = File.GetLastWriteTimeUtc(path); - - 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) - { - reporter.Verbose( - $"Ignoring file change during build: {kind} '{path}' " + - $"(created {FormatTimestamp(creationTime)} and written {FormatTimestamp(writeTime)} before {FormatTimestamp(buildCompletionTime)})."); - - return; - } - else if (writeTime > creationTime) - { - reporter.Verbose($"File change: {kind} '{path}' (written {FormatTimestamp(writeTime)} after {FormatTimestamp(buildCompletionTime)})."); - } - else - { - reporter.Verbose($"File change: {kind} '{path}' (created {FormatTimestamp(creationTime)} after {FormatTimestamp(buildCompletionTime)})."); - } - } - catch (Exception e) - { - reporter.Verbose($"Ignoring file '{path}' due to access error: {e.Message}."); - return; - } - } - - if (kind == ChangeKind.Delete) - { - reporter.Verbose($"File '{path}' deleted after {FormatTimestamp(buildCompletionTime)}."); - } - - if (kind == ChangeKind.Add) - { - lock (_changedFilesLock) - { - _changedFiles.TryAdd(path, new ChangedFile(new FileItem { FilePath = path }, kind)); - } - } - else if (fileSet.TryGetValue(path, out var fileItem)) - { - lock (_changedFilesLock) - { - _changedFiles.TryAdd(path, new ChangedFile(fileItem, kind)); - } - } - } - } - - public Task GetChangedFilesAsync(CancellationToken cancellationToken, bool forceWaitForNewUpdate = false) - { - EnsureInitialized(); - - var tcs = _tcs; - if (!forceWaitForNewUpdate && tcs is not null) - { - return tcs.Task; - } - - _tcs = tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - cancellationToken.Register(() => tcs.TrySetResult(null)); - return tcs.Task; - } - - internal static string FormatTimestamp(DateTime time) - => time.ToString("HH:mm:ss.fffffff"); - } -} diff --git a/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs index 52e82d3a73ce..5cfb0baf08e6 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs @@ -166,7 +166,7 @@ private IReadOnlyList GetMSBuildArguments(string watchListFilePath) if (environmentOptions.TestFlags.HasFlag(TestFlags.RunningAsTest)) #endif { - arguments.Add("/bl:DotnetWatch.GenerateWatchList.binlog"); + arguments.Add($"/bl:{Path.Combine(environmentOptions.TestOutput, "DotnetWatch.GenerateWatchList.binlog")}"); } arguments.AddRange(buildArguments); diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index dafd8c0ab7ef..7dc0c6bb6771 100644 --- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json +++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json @@ -2,8 +2,8 @@ "profiles": { "dotnet-watch": { "commandName": "Project", - "commandLineArgs": "--verbose /bl:DotnetRun.binlog", - "workingDirectory": "$(RepoRoot)src\\Assets\\TestProjects\\BlazorWasmWithLibrary\\blazorwasm", + "commandLineArgs": "--list", + "workingDirectory": "C:\\sdk\\artifacts\\tmp\\Debug\\BlazorWasm_Ap---8DA5F107", "environmentVariables": { "DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)" } diff --git a/test/dotnet-watch.Tests/FileWatcherTests.cs b/test/dotnet-watch.Tests/FileWatcherTests.cs index eeadd08ceefd..3614f25413e4 100644 --- a/test/dotnet-watch.Tests/FileWatcherTests.cs +++ b/test/dotnet-watch.Tests/FileWatcherTests.cs @@ -19,7 +19,7 @@ private async Task TestOperation( Action operation) { using var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling); - if (watcher is DotnetFileWatcher dotnetWatcher) + if (watcher is EventBasedDirectoryWatcher dotnetWatcher) { dotnetWatcher.Logger = m => output.WriteLine(m); } @@ -291,7 +291,7 @@ public async Task MultipleTriggers(bool usePolling) watcher.EnableRaisingEvents = false; } - private async Task AssertFileChangeRaisesEvent(string directory, IFileSystemWatcher watcher) + private async Task AssertFileChangeRaisesEvent(string directory, IDirectoryWatcher watcher) { var changedEv = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var expectedPath = Path.Combine(directory, Path.GetRandomFileName()); diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 8c46f7e490c2..86dafb654e92 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -128,9 +128,8 @@ class AppUpdateHandler await App.AssertOutputLineStartsWith("Updated"); - AssertEx.Contains( - "dotnet watch ⚠ [WatchHotReloadApp (net9.0)] Expected to find a static method 'ClearCache' or 'UpdateApplication' on type 'AppUpdateHandler, WatchHotReloadApp, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' but neither exists.", - App.Process.Output); + await App.WaitUntilOutputContains( + "dotnet watch ⚠ [WatchHotReloadApp (net9.0)] Expected to find a static method 'ClearCache' or 'UpdateApplication' on type 'AppUpdateHandler, WatchHotReloadApp, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' but neither exists."); } [Theory] @@ -169,18 +168,16 @@ class AppUpdateHandler await App.AssertOutputLineStartsWith("Updated"); - AssertEx.Contains( - "dotnet watch ⚠ [WatchHotReloadApp (net9.0)] Exception from 'System.Action`1[System.Type[]]': System.InvalidOperationException: Bug!", - App.Process.Output); + await App.WaitUntilOutputContains("dotnet watch ⚠ [WatchHotReloadApp (net9.0)] Exception from 'System.Action`1[System.Type[]]': System.InvalidOperationException: Bug!"); if (verbose) { - AssertEx.Contains("dotnet watch 🕵️ [WatchHotReloadApp (net9.0)] Deltas applied.", App.Process.Output); + App.AssertOutputContains("dotnet watch 🕵️ [WatchHotReloadApp (net9.0)] Deltas applied."); } else { // shouldn't see any agent messages: - AssertEx.DoesNotContain("🕵️", App.Process.Output); + App.AssertOutputDoesNotContain("🕵️"); } } @@ -190,13 +187,14 @@ public async Task BlazorWasm() var testAsset = TestAssets.CopyTestAsset("WatchBlazorWasm") .WithSource(); - App.Start(testAsset, [], testFlags: TestFlags.MockBrowser); + var port = TestOptions.GetTestPort(); + App.Start(testAsset, ["--urls", "http://localhost:" + port], testFlags: TestFlags.MockBrowser); await App.AssertWaitingForChanges(); App.AssertOutputContains(MessageDescriptor.ConfiguredToUseBrowserRefresh); App.AssertOutputContains(MessageDescriptor.ConfiguredToLaunchBrowser); - App.AssertOutputContains("dotnet watch ⌚ Launching browser: http://localhost:5000/"); + App.AssertOutputContains($"dotnet watch ⌚ Launching browser: http://localhost:{port}/"); // shouldn't see any agent messages (agent is not loaded into blazor-devserver): AssertEx.DoesNotContain("🕵️", App.Process.Output); @@ -225,7 +223,8 @@ public async Task BlazorWasm_MSBuildWarning() """)); }); - App.Start(testAsset, [], testFlags: TestFlags.MockBrowser); + var port = TestOptions.GetTestPort(); + App.Start(testAsset, ["--urls", "http://localhost:" + port], testFlags: TestFlags.MockBrowser); await App.AssertOutputLineStartsWith("dotnet watch ⚠ msbuild: [Warning] Duplicate source file"); await App.AssertWaitingForChanges(); diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 9c5cc5f1fb0c..85932e21c212 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -17,6 +17,9 @@ public enum TriggerEvent WaitingForChanges, } + private TestAsset CopyTestAsset(string assetName, params object[] testParameters) + => TestAssets.CopyTestAsset("WatchAppMultiProc", identifier: string.Join(";", testParameters)).WithSource(); + private static async Task Launch(string projectPath, TestRuntimeProcessLauncher service, string workingDirectory, CancellationToken cancellationToken) { var projectOptions = new ProjectOptions() @@ -58,8 +61,7 @@ private static async Task Launch(string projectPath, TestRuntime [CombinatorialData] public async Task UpdateAndRudeEdit(TriggerEvent trigger) { - var testAsset = TestAssets.CopyTestAsset("WatchAppMultiProc", identifier: trigger.ToString()) - .WithSource(); + var testAsset = CopyTestAsset("WatchAppMultiProc", trigger); var workingDirectory = testAsset.Path; var hostDir = Path.Combine(testAsset.Path, "Host"); @@ -80,7 +82,7 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger) var program = Program.TryCreate( TestOptions.GetCommandLineOptions(["--verbose", "--non-interactive", "--project", hostProject]), console, - TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath), + TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset), reporter, out var errorCode); @@ -137,20 +139,29 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger) await launchCompletionB.Task; // let the host process start: + Log("Waiting for changes..."); await waitingForChanges.WaitAsync(); + + Log("Waiting for session started..."); await sessionStarted.WaitAsync(); await MakeRudeEditChange(); + + Log("Waiting for changed handled ..."); await changeHandled.WaitAsync(); // Wait for a new session to start, so that we capture the new solution snapshot // and further changes are treated as another update. + Log("Waiting for session started..."); await sessionStarted.WaitAsync(); await MakeValidDependencyChange(); + + Log("Waiting for changed handled ..."); await changeHandled.WaitAsync(); // clean up: + Log("Shutting down"); watchCancellationSource.Cancel(); try { @@ -207,7 +218,10 @@ public static void Common() } """); + Log("Waiting for updated output from project A ..."); await hasUpdateSourceA.Task; + + Log("Waiting for updated output from project B ..."); await hasUpdateSourceB.Task; Assert.True(hasUpdateSourceA.Task.IsCompletedSuccessfully); @@ -233,6 +247,7 @@ async Task MakeRudeEditChange() [assembly: System.Reflection.AssemblyMetadata("TestAssemblyMetadata", "2")] """); + Log("Waiting for updated output from project A ..."); await hasUpdateSource.Task; Assert.True(hasUpdateSource.Task.IsCompletedSuccessfully); @@ -245,8 +260,7 @@ async Task MakeRudeEditChange() [CombinatorialData] public async Task UpdateAppliedToNewProcesses(bool sharedOutput) { - var testAsset = TestAssets.CopyTestAsset("WatchAppMultiProc", identifier: sharedOutput.ToString()) - .WithSource(); + var testAsset = CopyTestAsset("WatchAppMultiProc", sharedOutput); if (sharedOutput) { @@ -270,7 +284,7 @@ public async Task UpdateAppliedToNewProcesses(bool sharedOutput) var program = Program.TryCreate( TestOptions.GetCommandLineOptions(["--verbose", "--non-interactive", "--project", hostProject]), console, - TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath), + TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset), reporter, out var errorCode); @@ -313,9 +327,8 @@ public async Task UpdateAppliedToNewProcesses(bool sharedOutput) } }; - await Task.Delay(TimeSpan.FromSeconds(1)); - // let the host process start: + Log("Waiting for changes..."); await waitingForChanges.WaitAsync(); // service should have been created before Hot Reload session started: @@ -336,19 +349,27 @@ public static void Common() } """); + Log("Waiting for updated output from A ..."); await hasUpdateA.WaitAsync(); // Host and ServiceA received updates: + Log("Waiting for updates applied 1/2 ..."); await updatesApplied.WaitAsync(); + + Log("Waiting for updates applied 2/2 ..."); await updatesApplied.WaitAsync(); await Launch(serviceProjectB, service, workingDirectory, watchCancellationSource.Token); // ServiceB received updates: + Log("Waiting for updates applied ..."); await updatesApplied.WaitAsync(); + + Log("Waiting for updated output from B ..."); await hasUpdateB.WaitAsync(); // clean up: + Log("Shutting down"); watchCancellationSource.Cancel(); try { @@ -370,8 +391,7 @@ public enum UpdateLocation [CombinatorialData] public async Task HostRestart(UpdateLocation updateLocation) { - var testAsset = TestAssets.CopyTestAsset("WatchAppMultiProc", identifier: updateLocation.ToString()) - .WithSource(); + var testAsset = CopyTestAsset("WatchAppMultiProc", updateLocation); var workingDirectory = testAsset.Path; var hostDir = Path.Combine(testAsset.Path, "Host"); @@ -386,7 +406,7 @@ public async Task HostRestart(UpdateLocation updateLocation) var program = Program.TryCreate( TestOptions.GetCommandLineOptions(["--verbose", "--project", hostProject]), console, - TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath), + TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset), reporter, out var errorCode); @@ -428,6 +448,7 @@ public async Task HostRestart(UpdateLocation updateLocation) await Task.Delay(TimeSpan.FromSeconds(1)); // let the host process start: + Log("Waiting for changes..."); await waitingForChanges.WaitAsync(); switch (updateLocation) @@ -446,6 +467,7 @@ public static void Print() """); // Host received Hot Reload updates: + Log("Waiting for change handled ..."); await changeHandled.WaitAsync(); break; @@ -454,6 +476,7 @@ public static void Print() UpdateSourceFile(hostProgram, content => content.Replace("Waiting", "")); // Host received Hot Reload updates: + Log("Waiting for change handled ..."); await changeHandled.WaitAsync(); break; @@ -462,14 +485,17 @@ public static void Print() UpdateSourceFile(hostProgram, content => content.Replace("Started", "")); // ⚠ ENC0118: Changing 'top-level code' might not have any effect until the application is restarted. Press "Ctrl + R" to restart. + Log("Waiting for restart needed ..."); await restartNeeded.WaitAsync(); console.PressKey(new ConsoleKeyInfo('R', ConsoleKey.R, shift: false, alt: false, control: true)); + Log("Waiting for restart requested ..."); await restartRequested.WaitAsync(); break; } + Log("Waiting updated output from Host ..."); await hasUpdate.WaitAsync(); // clean up: @@ -486,8 +512,7 @@ public static void Print() [Fact] public async Task RudeEditInProjectWithoutRunningProcess() { - var testAsset = TestAssets.CopyTestAsset("WatchAppMultiProc") - .WithSource(); + var testAsset = CopyTestAsset("WatchAppMultiProc"); var workingDirectory = testAsset.Path; var hostDir = Path.Combine(testAsset.Path, "Host"); @@ -502,7 +527,7 @@ public async Task RudeEditInProjectWithoutRunningProcess() var program = Program.TryCreate( TestOptions.GetCommandLineOptions(["--verbose", "--non-interactive", "--project", hostProject]), console, - TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath), + TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset), reporter, out var errorCode); @@ -526,12 +551,14 @@ public async Task RudeEditInProjectWithoutRunningProcess() var sessionStarted = reporter.RegisterSemaphore(MessageDescriptor.HotReloadSessionStarted); // let the host process start: + Log("Waiting for changes..."); await waitingForChanges.WaitAsync(); // service should have been created before Hot Reload session started: Assert.NotNull(service); var runningProject = await Launch(serviceProjectA, service, workingDirectory, watchCancellationSource.Token); + Log("Waiting for session started ..."); await sessionStarted.WaitAsync(); // Terminate the process: @@ -542,6 +569,7 @@ public async Task RudeEditInProjectWithoutRunningProcess() [assembly: System.Reflection.AssemblyMetadata("TestAssemblyMetadata", "2")] """); + Log("Waiting for change handled ..."); await changeHandled.WaitAsync(); reporter.ProcessOutput.Contains("verbose ⌚ Rude edits detected but do not affect any running process"); diff --git a/test/dotnet-watch.Tests/Utilities/AwaitableProcess.cs b/test/dotnet-watch.Tests/Utilities/AwaitableProcess.cs index fb79e922dae8..c13ac86cdfbb 100644 --- a/test/dotnet-watch.Tests/Utilities/AwaitableProcess.cs +++ b/test/dotnet-watch.Tests/Utilities/AwaitableProcess.cs @@ -6,7 +6,7 @@ namespace Microsoft.DotNet.Watcher.Tools { - internal class AwaitableProcess : IDisposable + internal class AwaitableProcess(DotnetCommand spec, ITestOutputHelper logger) : IDisposable { // cancel just before we hit timeout used on CI (XUnitWorkItemTimeout value in sdk\test\UnitTests.proj) private static readonly TimeSpan s_timeout = Environment.GetEnvironmentVariable("HELIX_WORK_ITEM_TIMEOUT") is { } value @@ -14,29 +14,14 @@ internal class AwaitableProcess : IDisposable private readonly object _testOutputLock = new(); + private readonly DotnetCommand _spec = spec; + private readonly List _lines = []; + private readonly BufferBlock _source = new(); private Process _process; - private readonly DotnetCommand _spec; - private readonly List _lines; - private BufferBlock _source; - private ITestOutputHelper _logger; - private TaskCompletionSource _exited; private bool _disposed; - public AwaitableProcess(DotnetCommand spec, ITestOutputHelper logger) - { - _spec = spec; - _logger = logger; - _source = new BufferBlock(); - _lines = new List(); - _exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - public IEnumerable Output => _lines; - - public Task Exited => _exited.Task; - public int Id => _process.Id; - public Process Process => _process; public void Start() @@ -70,6 +55,9 @@ public void Start() WriteTestOutput($"{DateTime.Now}: process started: '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}'"); } + public void ClearOutput() + => _lines.Clear(); + public async Task GetOutputLineAsync(Predicate success, Predicate failure) { using var cancellationOnFailure = new CancellationTokenSource(); @@ -151,7 +139,7 @@ private void WriteTestOutput(string text) { if (!_disposed) { - _logger.WriteLine(text); + logger.WriteLine(text); } } } @@ -160,17 +148,9 @@ private void OnExit(object sender, EventArgs args) { // Wait to ensure the process has exited and all output consumed _process.WaitForExit(); - _source.Complete(); - _exited.TrySetResult(_process.ExitCode); - try - { - WriteTestOutput($"Process {_process.Id} has exited"); - } - catch - { - // test might not be running anymore - } + // Signal test methods waiting on all process output to be completed: + _source.Complete(); } public void Dispose() @@ -182,38 +162,40 @@ public void Dispose() _disposed = true; } - if (_process != null) + if (_process == null) { - try - { - _process.Kill(entireProcessTree: true); - } - catch - { - } + return; + } - try - { - _process.CancelErrorRead(); - } - catch - { - } + _process.ErrorDataReceived -= OnData; + _process.OutputDataReceived -= OnData; - try - { - _process.CancelOutputRead(); - } - catch - { - } + try + { + _process.CancelErrorRead(); + } + catch + { + } - _process.ErrorDataReceived -= OnData; - _process.OutputDataReceived -= OnData; - _process.Exited -= OnExit; - _process.Dispose(); - _process = null; + try + { + _process.CancelOutputRead(); + } + catch + { + } + + try + { + _process.Kill(entireProcessTree: false); + } + catch + { } + + _process.Dispose(); + _process = null; } } } diff --git a/test/dotnet-watch.Tests/Utilities/TestOptions.cs b/test/dotnet-watch.Tests/Utilities/TestOptions.cs index c083c93fdfa7..bbc9b51d2e25 100644 --- a/test/dotnet-watch.Tests/Utilities/TestOptions.cs +++ b/test/dotnet-watch.Tests/Utilities/TestOptions.cs @@ -1,23 +1,35 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + using Microsoft.Extensions.Tools.Internal; namespace Microsoft.DotNet.Watcher; internal static class TestOptions { + private static int s_testPort = 7000; + + public static int GetTestPort() + => Interlocked.Increment(ref s_testPort); + public static readonly ProjectOptions ProjectOptions = GetProjectOptions([]); - public static EnvironmentOptions GetEnvironmentOptions(string workingDirectory = "", string muxerPath = "") - => new(workingDirectory, muxerPath, TestFlags: TestFlags.RunningAsTest); + public static EnvironmentOptions GetEnvironmentOptions(string workingDirectory = "", string muxerPath = "", TestAsset? asset = null) + => new(workingDirectory, muxerPath, TestFlags: TestFlags.RunningAsTest, TestOutput: asset != null ? GetWatchTestOutputPath(asset) : ""); public static CommandLineOptions GetCommandLineOptions(string[] args) - => CommandLineOptions.Parse(args, NullReporter.Singleton, TextWriter.Null, out _); + => CommandLineOptions.Parse(args, NullReporter.Singleton, TextWriter.Null, out _) ?? throw new InvalidOperationException(); - public static ProjectOptions GetProjectOptions(string[] args = null) + public static ProjectOptions GetProjectOptions(string[]? args = null) { var options = GetCommandLineOptions(args ?? []); return options.GetProjectOptions(options.ProjectPath ?? "test.csproj", workingDirectory: ""); } + + public static string GetWatchTestOutputPath(this TestAsset asset) + => Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT") is { } ciOutputRoot + ? Path.Combine(ciOutputRoot, ".hotreload", asset.Name) + : asset.Path + ".hotreload"; } diff --git a/test/dotnet-watch.Tests/Watch/DotNetWatcherTests.cs b/test/dotnet-watch.Tests/Watch/DotNetWatcherTests.cs index fb64fbad9567..50cbf578d2ce 100644 --- a/test/dotnet-watch.Tests/Watch/DotNetWatcherTests.cs +++ b/test/dotnet-watch.Tests/Watch/DotNetWatcherTests.cs @@ -100,7 +100,7 @@ public async Task RunsWithIterationEnvVariable() var value = await App.AssertOutputLineStartsWith(messagePrefix); Assert.Equal(1, int.Parse(value, CultureInfo.InvariantCulture)); - await App.AssertWaitingForChanges(); + await App.AssertOutputLineStartsWith(MessageDescriptor.WaitingForFileChangeBeforeRestarting); UpdateSourceFile(source); await App.AssertStarted(); diff --git a/test/dotnet-watch.Tests/Watch/ProgramTests.cs b/test/dotnet-watch.Tests/Watch/ProgramTests.cs index 0dcc2a850e0f..43319ebdd215 100644 --- a/test/dotnet-watch.Tests/Watch/ProgramTests.cs +++ b/test/dotnet-watch.Tests/Watch/ProgramTests.cs @@ -23,7 +23,7 @@ public async Task ConsoleCancelKey() var program = Program.TryCreate( TestOptions.GetCommandLineOptions(["--verbose"]), console, - TestOptions.GetEnvironmentOptions(workingDirectory: testAsset.Path, TestContext.Current.ToolsetUnderTest.DotNetHostPath), + TestOptions.GetEnvironmentOptions(workingDirectory: testAsset.Path, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset), reporter, out var errorCode); @@ -196,16 +196,21 @@ public async Task TestCommand() App.Start(testAsset, ["--verbose", "test", "--list-tests", "/p:VSTestUseMSBuildOutput=false"]); - await App.AssertOutputLineEquals("The following Tests are available:"); - await App.AssertOutputLineEquals(" TestNamespace.VSTestXunitTests.VSTestXunitPassTest"); + await App.AssertOutputLineStartsWith(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + + App.AssertOutputContains("The following Tests are available:"); + App.AssertOutputContains(" TestNamespace.VSTestXunitTests.VSTestXunitPassTest"); + App.Process.ClearOutput(); // update file: var testFile = Path.Combine(testAsset.Path, "UnitTest1.cs"); var content = File.ReadAllText(testFile, Encoding.UTF8); File.WriteAllText(testFile, content.Replace("VSTestXunitPassTest", "VSTestXunitPassTest2"), Encoding.UTF8); - await App.AssertOutputLineEquals("The following Tests are available:"); - await App.AssertOutputLineEquals(" TestNamespace.VSTestXunitTests.VSTestXunitPassTest2"); + await App.AssertOutputLineStartsWith(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + + App.AssertOutputContains("The following Tests are available:"); + App.AssertOutputContains(" TestNamespace.VSTestXunitTests.VSTestXunitPassTest2"); } [Fact] diff --git a/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs b/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs index 780b5a7d39cb..70c38986a1b6 100644 --- a/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs +++ b/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs @@ -25,16 +25,19 @@ public DotNetWatchTestBase(ITestOutputHelper logger) public DebugTestOutputLogger Logger => (DebugTestOutputLogger)App.Logger; + public void Log(string message) + => Logger.WriteLine($"[TEST] {message}"); + public void UpdateSourceFile(string path, string text) { File.WriteAllText(path, text, Encoding.UTF8); - Logger.WriteLine($"File '{path}' updated ({HotReloadFileSetWatcher.FormatTimestamp(File.GetLastWriteTimeUtc(path))})."); + 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); - Logger.WriteLine($"File '{path}' updated."); + Log($"File '{path}' updated."); } public void UpdateSourceFile(string path) diff --git a/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs b/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs index 3a7c74b2fa71..26fe6b0b99c8 100644 --- a/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs +++ b/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs @@ -37,6 +37,9 @@ public static string GetLinePrefix(MessageDescriptor descriptor, string projectD public void AssertOutputContains(string message) => AssertEx.Contains(message, Process.Output); + public void AssertOutputDoesNotContain(string message) + => AssertEx.DoesNotContain(message, Process.Output); + public void AssertOutputContains(MessageDescriptor descriptor, string projectDisplay = null) => AssertOutputContains(GetLinePrefix(descriptor, projectDisplay)); @@ -117,15 +120,14 @@ public void Start(TestAsset asset, IEnumerable arguments, string relativ WorkingDirectory = workingDirectory ?? projectDirectory, }; + var testOutputPath = asset.GetWatchTestOutputPath(); + Directory.CreateDirectory(testOutputPath); + commandSpec.WithEnvironmentVariable("HOTRELOAD_DELTA_CLIENT_LOG_MESSAGES", "1"); commandSpec.WithEnvironmentVariable("DOTNET_USE_POLLING_FILE_WATCHER", "true"); commandSpec.WithEnvironmentVariable("__DOTNET_WATCH_TEST_FLAGS", testFlags.ToString()); - - var encLogPath = Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT") is { } ciOutputRoot - ? Path.Combine(ciOutputRoot, ".hotreload", asset.Name) - : asset.Path + ".hotreload"; - - commandSpec.WithEnvironmentVariable("Microsoft_CodeAnalysis_EditAndContinue_LogDir", encLogPath); + commandSpec.WithEnvironmentVariable("__DOTNET_WATCH_TEST_OUTPUT_DIR", testOutputPath); + commandSpec.WithEnvironmentVariable("Microsoft_CodeAnalysis_EditAndContinue_LogDir", testOutputPath); foreach (var env in EnvironmentVariables) {