diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs index 9c4ab9f433c7..9800408770fa 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.WatchDirectories(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/HotReload/DefaultDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs index 0bdceee705ca..b8a99e3aa17b 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 513c6b0548b0..8d36fb37691f 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,52 @@ 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)); + } + } + + 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 +232,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 +244,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 +338,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 +352,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 +379,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 +387,7 @@ await Task.WhenAll( } finally { - fileSetWatcherTask = null; + fileWatcherTask = null; if (runtimeProcessLauncher != null) { @@ -364,22 +396,119 @@ 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); + } - fileSetWatcher?.Dispose(); + _ = 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); + } + } + + 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 (kind == ChangeKind.Add) + { + return new ChangedFile(new FileItem { FilePath = path }, kind); + } + + if (fileSet.TryGetValue(path, out var fileItem)) + { + return new ChangedFile(fileItem, 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) diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs index 74e9ec62d82b..38eda4596e44 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs @@ -5,7 +5,7 @@ namespace Microsoft.DotNet.Watcher.Internal { - internal sealed class FileWatcher(IReadOnlyDictionary fileSet, IReporter reporter) : IDisposable + internal sealed class FileWatcher(IReporter reporter) : IDisposable { // Directory watcher for each watched directory private readonly Dictionary _watchers = []; @@ -30,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) { 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)) @@ -70,7 +76,7 @@ private void WatcherErrorHandler(object? sender, Exception error) { if (sender is IDirectoryWatcher watcher) { - reporter.Warn($"The file watcher observing '{watcher.Directory}' encountered an error: {error.Message}"); + reporter.Warn($"The file watcher observing '{watcher.WatchedDirectory}' encountered an error: {error.Message}"); } } @@ -94,18 +100,22 @@ private void DisposeWatcher(string directory) 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); } } @@ -125,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, 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: + null, cancellationToken); + + if (fileChange != null) + { + reporter.Output($"File changed: {filePath}"); + } } } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs index 871ededea7ea..9c94c7b49b81 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs @@ -12,9 +12,9 @@ internal sealed class EventBasedDirectoryWatcher : IDirectoryWatcher public event EventHandler? OnError; - public string Directory { get; } + public string WatchedDirectory { get; } - internal Action? Logger { get; set; } + internal Action? Logger { get; set; } private volatile bool _disposed; @@ -24,10 +24,16 @@ internal sealed class EventBasedDirectoryWatcher : IDirectoryWatcher internal EventBasedDirectoryWatcher(string watchedDirectory) { - Directory = watchedDirectory; + WatchedDirectory = watchedDirectory; CreateFileSystemWatcher(); } + public void Dispose() + { + _disposed = true; + DisposeInnerWatcher(); + } + private void WatcherErrorHandler(object sender, ErrorEventArgs e) { if (_disposed) @@ -65,9 +71,9 @@ private void WatcherRenameHandler(object sender, RenamedEventArgs e) NotifyChange(e.OldFullPath, ChangeKind.Delete); NotifyChange(e.FullPath, ChangeKind.Add); - if (System.IO.Directory.Exists(e.FullPath)) + if (Directory.Exists(e.FullPath)) { - foreach (var newLocation in System.IO.Directory.EnumerateFileSystemEntries(e.FullPath, "*", SearchOption.AllDirectories)) + foreach (var newLocation in Directory.EnumerateFileSystemEntries(e.FullPath, "*", SearchOption.AllDirectories)) { // Calculated previous path of this moved item. var oldLocation = Path.Combine(e.OldFullPath, newLocation.Substring(e.FullPath.Length + 1)); @@ -129,7 +135,7 @@ private void CreateFileSystemWatcher() DisposeInnerWatcher(); } - _fileSystemWatcher = new FileSystemWatcher(Directory) + _fileSystemWatcher = new FileSystemWatcher(WatchedDirectory) { IncludeSubdirectories = true }; @@ -146,7 +152,7 @@ private void CreateFileSystemWatcher() private void DisposeInnerWatcher() { - if ( _fileSystemWatcher != null ) + if (_fileSystemWatcher != null) { _fileSystemWatcher.EnableRaisingEvents = false; @@ -165,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/IDirectoryWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs index d8c19798d656..4cd187cb6fe0 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/IDirectoryWatcher.cs @@ -9,7 +9,7 @@ internal interface IDirectoryWatcher : IDisposable event EventHandler OnError; - string Directory { get; } + string WatchedDirectory { get; } bool EnableRaisingEvents { get; set; } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs index 018723b938db..df49f214adce 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileWatcher/PollingDirectoryWatcher.cs @@ -13,21 +13,29 @@ internal sealed class PollingDirectoryWatcher : IDirectoryWatcher 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 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); - Directory = _watchedDirectory.FullName; + WatchedDirectory = _watchedDirectory.FullName; _pollingThread = new Thread(new ThreadStart(PollingLoop)) { @@ -40,20 +48,18 @@ public PollingDirectoryWatcher(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 Directory { 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(PollingDirectoryWatcher)); - } - } - - 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/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index dafd8c0ab7ef..19aafd862261 100644 --- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json +++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json @@ -3,7 +3,7 @@ "dotnet-watch": { "commandName": "Project", "commandLineArgs": "--verbose /bl:DotnetRun.binlog", - "workingDirectory": "$(RepoRoot)src\\Assets\\TestProjects\\BlazorWasmWithLibrary\\blazorwasm", + "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/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 6e64e9e91dc9..a269de869d5f 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -190,13 +190,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}/"); var newSource = """ @page "/" @@ -222,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/Utilities/AwaitableProcess.cs b/test/dotnet-watch.Tests/Utilities/AwaitableProcess.cs index fb79e922dae8..ec21aa2016a7 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() @@ -61,7 +46,6 @@ public void Start() _process.OutputDataReceived += OnData; _process.ErrorDataReceived += OnData; - _process.Exited += OnExit; WriteTestOutput($"{DateTime.Now}: starting process: '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}'"); _process.Start(); @@ -151,69 +135,54 @@ private void WriteTestOutput(string text) { if (!_disposed) { - _logger.WriteLine(text); + logger.WriteLine(text); } } } - private void OnExit(object sender, EventArgs args) + public void Dispose() { - // Wait to ensure the process has exited and all output consumed - _process.WaitForExit(); _source.Complete(); - _exited.TrySetResult(_process.ExitCode); - try + lock (_testOutputLock) { - WriteTestOutput($"Process {_process.Id} has exited"); + _disposed = true; } - catch + + if (_process == null) { - // test might not be running anymore + return; } - } - public void Dispose() - { - _source.Complete(); + _process.ErrorDataReceived -= OnData; + _process.OutputDataReceived -= OnData; - lock (_testOutputLock) + try { - _disposed = true; + _process.CancelErrorRead(); } - - if (_process != null) + catch { - try - { - _process.Kill(entireProcessTree: true); - } - catch - { - } - - try - { - _process.CancelErrorRead(); - } - catch - { - } + } - try - { - _process.CancelOutputRead(); - } - catch - { - } + try + { + _process.CancelOutputRead(); + } + catch + { + } - _process.ErrorDataReceived -= OnData; - _process.OutputDataReceived -= OnData; - _process.Exited -= OnExit; - _process.Dispose(); - _process = null; + 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..7da0967aa5c8 100644 --- a/test/dotnet-watch.Tests/Utilities/TestOptions.cs +++ b/test/dotnet-watch.Tests/Utilities/TestOptions.cs @@ -7,6 +7,11 @@ 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 = "") diff --git a/test/dotnet-watch.Tests/Watch/DotNetWatcherTests.cs b/test/dotnet-watch.Tests/Watch/DotNetWatcherTests.cs index 3635cb163001..50cbf578d2ce 100644 --- a/test/dotnet-watch.Tests/Watch/DotNetWatcherTests.cs +++ b/test/dotnet-watch.Tests/Watch/DotNetWatcherTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Microsoft.Extensions.Tools.Internal; namespace Microsoft.DotNet.Watcher.Tests { @@ -99,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/Utilities/DotNetWatchTestBase.cs b/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs index 780b5a7d39cb..cccad91d75f2 100644 --- a/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs +++ b/test/dotnet-watch.Tests/Watch/Utilities/DotNetWatchTestBase.cs @@ -28,7 +28,7 @@ public DotNetWatchTestBase(ITestOutputHelper logger) public void UpdateSourceFile(string path, string text) { File.WriteAllText(path, text, Encoding.UTF8); - Logger.WriteLine($"File '{path}' updated ({HotReloadFileSetWatcher.FormatTimestamp(File.GetLastWriteTimeUtc(path))})."); + Logger.WriteLine($"File '{path}' updated ({HotReloadDotNetWatcher.FormatTimestamp(File.GetLastWriteTimeUtc(path))})."); } public void UpdateSourceFile(string path, Func contentTransform)