From 6dca35295e15461b488afe32fe4f6a7640c30652 Mon Sep 17 00:00:00 2001 From: tmat Date: Mon, 15 Dec 2025 09:11:05 -0800 Subject: [PATCH 1/4] Switch to HotReloadMSBuildWorkspace --- Directory.Packages.props | 1 + eng/Version.Details.props | 25 +- eng/Version.Details.xml | 4 + .../Watch/Build/EvaluationResult.cs | 23 +- .../Watch/Build/FilePathExclusions.cs | 2 +- .../Watch/Build/ProjectGraphUtilities.cs | 4 +- .../Watch/FileWatcher/ChangeKind.cs | 14 + .../Watch/HotReload/CompilationHandler.cs | 77 ++++- .../Watch/HotReload/HotReloadDotNetWatcher.cs | 24 +- .../HotReload/IncrementalMSBuildWorkspace.cs | 282 ------------------ .../HotReload/CompilationHandlerTests.cs | 2 +- 11 files changed, 142 insertions(+), 316 deletions(-) delete mode 100644 src/BuiltInTools/Watch/HotReload/IncrementalMSBuildWorkspace.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a44ed722182f..3e9b3bd272de 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,6 +37,7 @@ + diff --git a/eng/Version.Details.props b/eng/Version.Details.props index d6708ed330ab..507c09944507 100644 --- a/eng/Version.Details.props +++ b/eng/Version.Details.props @@ -11,17 +11,18 @@ This file should be imported by eng/Versions.props 18.3.0-preview-26076-108 7.3.0-preview.1.7708 10.0.300-alpha.26076.108 - 5.3.0-2.26076.108 - 5.3.0-2.26076.108 - 5.3.0-2.26076.108 - 5.3.0-2.26076.108 - 5.3.0-2.26076.108 - 5.3.0-2.26076.108 - 5.3.0-2.26076.108 - 5.3.0-2.26076.108 + 5.3.0-2.25610.11 + 5.3.0-2.25610.11 + 5.3.0-2.25610.11 + 5.3.0-2.25610.11 + 5.3.0-2.25610.11 + 5.3.0-2.25610.11 + 5.3.0-2.25610.11 + 5.3.0-2.25610.11 + 5.3.0-2.25610.11 10.0.0-preview.26076.108 - 5.3.0-2.26076.108 - 5.3.0-2.26076.108 + 5.3.0-2.25610.11 + 5.3.0-2.25610.11 10.0.0-beta.26076.108 10.0.0-beta.26076.108 10.0.0-beta.26076.108 @@ -31,8 +32,8 @@ This file should be imported by eng/Versions.props 10.0.0-beta.26076.108 10.0.0-beta.26076.108 15.2.300-servicing.26076.108 - 5.3.0-2.26076.108 - 5.3.0-2.26076.108 + 5.3.0-2.25610.11 + 5.3.0-2.25610.11 10.0.0-preview.7.25377.103 10.0.0-preview.26076.108 18.3.0-release-26076-108 diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index cde5e4dcbb9c..c420311e369e 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -128,6 +128,10 @@ https://github.com/dotnet/dotnet 3b3cc2b93b356d46a6fb36768479ea53515fc2cd + + https://github.com/dotnet/roslyn + 46a48b8c1dfce7c35da115308bedd6a5954fd78a + https://github.com/dotnet/dotnet 3b3cc2b93b356d46a6fb36768479ea53515fc2cd diff --git a/src/BuiltInTools/Watch/Build/EvaluationResult.cs b/src/BuiltInTools/Watch/Build/EvaluationResult.cs index 829f527774e0..45b9d9fb0e6f 100644 --- a/src/BuiltInTools/Watch/Build/EvaluationResult.cs +++ b/src/BuiltInTools/Watch/Build/EvaluationResult.cs @@ -9,7 +9,11 @@ namespace Microsoft.DotNet.Watch; -internal sealed class EvaluationResult(ProjectGraph projectGraph, IReadOnlyDictionary files, IReadOnlyDictionary staticWebAssetsManifests) +internal sealed class EvaluationResult( + ProjectGraph projectGraph, + ImmutableArray restoredProjectInstances, + IReadOnlyDictionary files, + IReadOnlyDictionary staticWebAssetsManifests) { public readonly IReadOnlyDictionary Files = files; public readonly ProjectGraph ProjectGraph = projectGraph; @@ -31,6 +35,9 @@ public IReadOnlySet BuildFiles public IReadOnlyDictionary StaticWebAssetsManifests => staticWebAssetsManifests; + public ImmutableArray RestoredProjectInstances + => restoredProjectInstances; + public void WatchFiles(FileWatcher fileWatcher) { fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true); @@ -94,15 +101,19 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera } } + // Capture the snapshot of original project instances after Restore target has been run. + // These instances can be used to evaluate additional targets (e.g. deployment) if needed. + var restoredProjectInstances = projectGraph.ProjectNodesTopologicallySorted.Select(node => node.ProjectInstance.DeepCopy()).ToImmutableArray(); + var fileItems = new Dictionary(); var staticWebAssetManifests = new Dictionary(); + // Update the project instances of the graph with design-time build results. + // The properties and items set by DTB will be used by the Workspace to create Roslyn representation of projects. + foreach (var project in projectGraph.ProjectNodesTopologicallySorted) { - // Deep copy so that we can reuse the graph for building additional targets later on. - // If we didn't copy the instance the targets might duplicate items that were already - // populated by design-time build. - var projectInstance = project.ProjectInstance.DeepCopy(); + var projectInstance = project.ProjectInstance; // skip outer build project nodes: if (projectInstance.GetPropertyValue(PropertyNames.TargetFramework) == "") @@ -189,7 +200,7 @@ void AddFile(string relativePath, string? staticWebAssetRelativeUrl) buildReporter.ReportWatchedFiles(fileItems); - return new EvaluationResult(projectGraph, fileItems, staticWebAssetManifests); + return new EvaluationResult(projectGraph, restoredProjectInstances, fileItems, staticWebAssetManifests); } private static string[] GetBuildTargets(ProjectInstance projectInstance, EnvironmentOptions environmentOptions) diff --git a/src/BuiltInTools/Watch/Build/FilePathExclusions.cs b/src/BuiltInTools/Watch/Build/FilePathExclusions.cs index b4f921d875f6..c98def116a2f 100644 --- a/src/BuiltInTools/Watch/Build/FilePathExclusions.cs +++ b/src/BuiltInTools/Watch/Build/FilePathExclusions.cs @@ -39,7 +39,7 @@ public static FilePathExclusions Create(ProjectGraph projectGraph) { // If default items are not enabled exclude just the output directories. - TryAddOutputDir(projectNode.GetOutputDirectory()); + TryAddOutputDir(projectNode.ProjectInstance.GetOutputDirectory()); TryAddOutputDir(projectNode.ProjectInstance.GetIntermediateOutputDirectory()); void TryAddOutputDir(string? dir) diff --git a/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs b/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs index 111dff988e47..4adce83dc6bd 100644 --- a/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs +++ b/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs @@ -51,8 +51,8 @@ public static bool IsNetCoreApp(this ProjectGraphNode projectNode, Version minVe public static bool IsWebApp(this ProjectGraphNode projectNode) => projectNode.GetCapabilities().Any(static value => value is ProjectCapability.AspNetCore or ProjectCapability.WebAssembly); - public static string? GetOutputDirectory(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetPath) is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(projectNode.ProjectInstance.Directory, path)) : null; + public static string? GetOutputDirectory(this ProjectInstance project) + => project.GetPropertyValue(PropertyNames.TargetPath) is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(project.Directory, path)) : null; public static string GetAssemblyName(this ProjectGraphNode projectNode) => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetName); diff --git a/src/BuiltInTools/Watch/FileWatcher/ChangeKind.cs b/src/BuiltInTools/Watch/FileWatcher/ChangeKind.cs index 71e339419a1c..631b13ae5d87 100644 --- a/src/BuiltInTools/Watch/FileWatcher/ChangeKind.cs +++ b/src/BuiltInTools/Watch/FileWatcher/ChangeKind.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; + namespace Microsoft.DotNet.Watch; internal enum ChangeKind @@ -10,6 +12,18 @@ internal enum ChangeKind Delete } +internal static class ChangeKindExtensions +{ + public static HotReloadFileChangeKind Convert(this ChangeKind changeKind) => + changeKind switch + { + ChangeKind.Update => HotReloadFileChangeKind.Update, + ChangeKind.Add => HotReloadFileChangeKind.Add, + ChangeKind.Delete => HotReloadFileChangeKind.Delete, + _ => throw new InvalidOperationException() + }; +} + internal readonly record struct ChangedFile(FileItem Item, ChangeKind Kind); internal readonly record struct ChangedPath(string Path, ChangeKind Kind); diff --git a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs index 360d49ebf8ae..ba8c6de821c5 100644 --- a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs @@ -15,7 +15,7 @@ namespace Microsoft.DotNet.Watch { internal sealed class CompilationHandler : IDisposable { - public readonly IncrementalMSBuildWorkspace Workspace; + public readonly HotReloadMSBuildWorkspace Workspace; private readonly DotNetWatchContext _context; private readonly HotReloadService _hotReloadService; @@ -37,11 +37,19 @@ internal sealed class CompilationHandler : IDisposable private ImmutableList _previousUpdates = []; private bool _isDisposed; + private int _solutionUpdateId; + + /// + /// Current set of project instances indexed by . + /// Updated whenever the project graph changes. + /// + private ImmutableDictionary> _projectInstances = []; public CompilationHandler(DotNetWatchContext context) { _context = context; - Workspace = new IncrementalMSBuildWorkspace(context.Logger); + _processRunner = processRunner; + Workspace = new HotReloadMSBuildWorkspace(logger, projectFile => (instances: _projectInstances.GetValueOrDefault(projectFile, []), project: null)); _hotReloadService = new HotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities())); } @@ -832,5 +840,70 @@ private static Task ForEachProjectAsync(ImmutableDictionary ToManagedCodeUpdates(ImmutableArray updates) => [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))]; + + private static ImmutableDictionary> CreateProjectInstanceMap(ProjectGraph graph) + => graph.ProjectNodes + .GroupBy(static node => node.ProjectInstance.FullPath) + .ToImmutableDictionary( + keySelector: static group => group.Key, + elementSelector: static group => group.Select(static node => node.ProjectInstance).ToImmutableArray()); + + public async Task UpdateProjectConeAsync(ProjectGraph projectGraph, string projectPath, CancellationToken cancellationToken) + { + _logger.LogInformation("Loading projects ..."); + var stopwatch = Stopwatch.StartNew(); + + _projectInstances = CreateProjectInstanceMap(projectGraph); + + var solution = await Workspace.UpdateProjectConeAsync(projectPath, cancellationToken); + await SolutionUpdatedAsync(solution, "project update", cancellationToken); + + _logger.LogInformation("Projects loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); + } + + public async Task UpdateFileContentAsync(ImmutableList changedFiles, CancellationToken cancellationToken) + { + var solution = await Workspace.UpdateFileContentAsync(changedFiles.Select(static f => (f.Item.FilePath, f.Kind.Convert())), cancellationToken); + await SolutionUpdatedAsync(solution, "document update", cancellationToken); + } + + private Task SolutionUpdatedAsync(Solution newSolution, string operationDisplayName, CancellationToken cancellationToken) + => ReportSolutionFilesAsync(newSolution, Interlocked.Increment(ref _solutionUpdateId), operationDisplayName, cancellationToken); + + private async Task ReportSolutionFilesAsync(Solution solution, int updateId, string operationDisplayName, CancellationToken cancellationToken) + { + _logger.LogDebug("Solution after {Operation}: v{Version}", operationDisplayName, updateId); + + if (!_logger.IsEnabled(LogLevel.Trace)) + { + return; + } + + foreach (var project in solution.Projects) + { + _logger.LogDebug(" Project: {Path}", project.FilePath); + + foreach (var document in project.Documents) + { + await InspectDocumentAsync(document, "Document").ConfigureAwait(false); + } + + foreach (var document in project.AdditionalDocuments) + { + await InspectDocumentAsync(document, "Additional").ConfigureAwait(false); + } + + foreach (var document in project.AnalyzerConfigDocuments) + { + await InspectDocumentAsync(document, "Config").ConfigureAwait(false); + } + } + + async ValueTask InspectDocumentAsync(TextDocument document, string kind) + { + var text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + _logger.LogDebug(" {Kind}: {FilePath} [{Checksum}]", kind, document.FilePath, Convert.ToBase64String(text.GetChecksum().ToArray())); + } + } } } diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs index 93cde1a44a06..8b5f27544f23 100644 --- a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -167,7 +167,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) return; } - await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.ProjectPath, iterationCancellationToken); + await compilationHandler.UpdateProjectConeAsync(evaluationResult.ProjectGraph, rootProjectOptions.ProjectPath, iterationCancellationToken); // Solution must be initialized after we load the solution but before we start watching for file changes to avoid race condition // when the EnC session captures content of the file after the changes has already been made. @@ -370,7 +370,7 @@ void FileChangedCallback(ChangedPath change) // Deploy dependencies after rebuilding and before restarting. if (!projectsToRedeploy.IsEmpty) { - DeployProjectDependencies(evaluationResult.ProjectGraph, projectsToRedeploy, iterationCancellationToken); + DeployProjectDependencies(evaluationResult.RestoredProjectInstances, projectsToRedeploy, iterationCancellationToken); _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, projectsToRedeploy.Length); } @@ -441,7 +441,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr // additional files/directories may have been added: evaluationResult.WatchFiles(fileWatcher); - await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.ProjectPath, iterationCancellationToken); + await compilationHandler.UpdateProjectConeAsync(evaluationResult.ProjectGraph, rootProjectOptions.ProjectPath, iterationCancellationToken); if (shutdownCancellationToken.IsCancellationRequested) { @@ -493,7 +493,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr { // Update the workspace to reflect changes in the file content:. // If the project was re-evaluated the Roslyn solution is already up to date. - await compilationHandler.Workspace.UpdateFileContentAsync(changedFiles, iterationCancellationToken); + await compilationHandler.UpdateFileContentAsync(changedFiles, iterationCancellationToken); } return [.. changedFiles]; @@ -552,6 +552,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr } } +<<<<<<< HEAD private void AnalyzeFileChanges( List changedFiles, EvaluationResult evaluationResult, @@ -650,35 +651,38 @@ private static bool MatchesStaticWebAssetFilePattern(EvaluationResult evaluation return false; } - private void DeployProjectDependencies(ProjectGraph graph, ImmutableArray projectPaths, CancellationToken cancellationToken) + private void DeployProjectDependencies(ImmutableArray restoredProjectInstances, ImmutableArray projectPaths, CancellationToken cancellationToken) { var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer); var buildReporter = new BuildReporter(_context.Logger, _context.Options, _context.EnvironmentOptions); var targetName = TargetNames.ReferenceCopyLocalPathsOutputGroup; - foreach (var node in graph.ProjectNodes) + foreach (var restoredProjectInstance in restoredProjectInstances) { cancellationToken.ThrowIfCancellationRequested(); - var projectPath = node.ProjectInstance.FullPath; + // Avoid modification of the restored snapshot. + var projectInstance = restoredProjectInstance.DeepCopy(); + + var projectPath = projectInstance.FullPath; if (!projectPathSet.Contains(projectPath)) { continue; } - if (!node.ProjectInstance.Targets.ContainsKey(targetName)) + if (!projectInstance.Targets.ContainsKey(targetName)) { continue; } - if (node.GetOutputDirectory() is not { } relativeOutputDir) + if (projectInstance.GetOutputDirectory() is not { } relativeOutputDir) { continue; } using var loggers = buildReporter.GetLoggers(projectPath, targetName); - if (!node.ProjectInstance.Build([targetName], loggers, out var targetOutputs)) + if (!projectInstance.Build([targetName], loggers, out var targetOutputs)) { _context.Logger.LogDebug("{TargetName} target failed", targetName); loggers.ReportOutput(); diff --git a/src/BuiltInTools/Watch/HotReload/IncrementalMSBuildWorkspace.cs b/src/BuiltInTools/Watch/HotReload/IncrementalMSBuildWorkspace.cs deleted file mode 100644 index 1581a57f3c66..000000000000 --- a/src/BuiltInTools/Watch/HotReload/IncrementalMSBuildWorkspace.cs +++ /dev/null @@ -1,282 +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.Immutable; -using System.Diagnostics; -using System.Reflection; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; -using Microsoft.CodeAnalysis.Host.Mef; -using Microsoft.CodeAnalysis.MSBuild; -using Microsoft.CodeAnalysis.Text; -using Microsoft.Extensions.Logging; - -namespace Microsoft.DotNet.Watch; - -internal sealed class IncrementalMSBuildWorkspace : Workspace -{ - private readonly ILogger _logger; - private int _solutionUpdateId; - - public IncrementalMSBuildWorkspace(ILogger logger) - : base(MSBuildMefHostServices.DefaultServices, WorkspaceKind.MSBuild) - { -#pragma warning disable CS0618 // https://github.com/dotnet/sdk/issues/49725 - WorkspaceFailed += (_sender, diag) => - { - // Report both Warning and Failure as warnings. - // MSBuildProjectLoader reports Failures for cases where we can safely continue loading projects - // (e.g. non-C#/VB project is ignored). - // https://github.com/dotnet/roslyn/issues/75170 - logger.LogWarning($"msbuild: {diag.Diagnostic}"); - }; -#pragma warning restore CS0618 - - _logger = logger; - } - - public async Task UpdateProjectConeAsync(string rootProjectPath, CancellationToken cancellationToken) - { - _logger.LogInformation("Loading projects ..."); - - var stopwatch = Stopwatch.StartNew(); - var oldSolution = CurrentSolution; - - var loader = new MSBuildProjectLoader(this); - var projectMap = ProjectMap.Create(); - - ImmutableArray projectInfos; - try - { - projectInfos = await loader.LoadProjectInfoAsync(rootProjectPath, projectMap, progress: null, msbuildLogger: null, cancellationToken).ConfigureAwait(false); - } - catch (InvalidOperationException) - { - // TODO: workaround for https://github.com/dotnet/roslyn/issues/75956 - projectInfos = []; - } - - var oldProjectIdsByPath = oldSolution.Projects.ToDictionary(keySelector: static p => (p.FilePath!, p.Name), elementSelector: static p => p.Id); - - // Map new project id to the corresponding old one based on file path and project name (includes TFM), if it exists, and null for added projects. - // Deleted projects won't be included in this map. - var projectIdMap = projectInfos.ToDictionary( - keySelector: static info => info.Id, - elementSelector: info => oldProjectIdsByPath.TryGetValue((info.FilePath!, info.Name), out var oldProjectId) ? oldProjectId : null); - - var newSolution = oldSolution; - - foreach (var newProjectInfo in projectInfos) - { - Debug.Assert(newProjectInfo.FilePath != null); - - var oldProjectId = projectIdMap[newProjectInfo.Id]; - if (oldProjectId == null) - { - newSolution = newSolution.AddProject(newProjectInfo); - continue; - } - - newSolution = HotReloadService.WithProjectInfo(newSolution, WithChecksumAlgorithm(ProjectInfo.Create( - oldProjectId, - newProjectInfo.Version, - newProjectInfo.Name, - newProjectInfo.AssemblyName, - newProjectInfo.Language, - newProjectInfo.FilePath, - newProjectInfo.OutputFilePath, - newProjectInfo.CompilationOptions, - newProjectInfo.ParseOptions, - MapDocuments(oldProjectId, newProjectInfo.Documents), - newProjectInfo.ProjectReferences.Select(MapProjectReference), - newProjectInfo.MetadataReferences, - newProjectInfo.AnalyzerReferences, - MapDocuments(oldProjectId, newProjectInfo.AdditionalDocuments), - isSubmission: false, - hostObjectType: null, - outputRefFilePath: newProjectInfo.OutputRefFilePath), - GetChecksumAlgorithm(newProjectInfo)) - .WithAnalyzerConfigDocuments(MapDocuments(oldProjectId, newProjectInfo.AnalyzerConfigDocuments)) - .WithCompilationOutputInfo(newProjectInfo.CompilationOutputInfo)); - } - - await UpdateSolutionAsync(newSolution, operationDisplayName: "project update", cancellationToken); - UpdateReferencesAfterAdd(); - - _logger.LogInformation("Projects loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); - - ProjectReference MapProjectReference(ProjectReference pr) - // Only C# and VB projects are loaded by the MSBuildProjectLoader, so some references might be missing. - // When a new project is added along with a new project reference the old project id is also null. - => new(projectIdMap.TryGetValue(pr.ProjectId, out var oldProjectId) && oldProjectId != null ? oldProjectId : pr.ProjectId, pr.Aliases, pr.EmbedInteropTypes); - - ImmutableArray MapDocuments(ProjectId mappedProjectId, IReadOnlyList documents) - => documents.Select(docInfo => - { - // TODO: can there be multiple documents of the same path in the project? - - // Map to a document of the same path. If there isn't one (a new document is added to the project), - // create a new document id with the mapped project id. - var mappedDocumentId = oldSolution.GetDocumentIdsWithFilePath(docInfo.FilePath).FirstOrDefault(id => id.ProjectId == mappedProjectId) - ?? DocumentId.CreateNewId(mappedProjectId); - - return docInfo.WithId(mappedDocumentId); - }).ToImmutableArray(); - } - - // TODO: remove - // workaround for https://github.com/dotnet/roslyn/pull/82051 - - private static MethodInfo? s_withChecksumAlgorithm; - private static PropertyInfo? s_getChecksumAlgorithm; - - private static ProjectInfo WithChecksumAlgorithm(ProjectInfo info, SourceHashAlgorithm algorithm) - => (ProjectInfo)(s_withChecksumAlgorithm ??= typeof(ProjectInfo).GetMethod("WithChecksumAlgorithm", BindingFlags.NonPublic | BindingFlags.Instance)!) - .Invoke(info, [algorithm])!; - - private static SourceHashAlgorithm GetChecksumAlgorithm(ProjectInfo info) - => (SourceHashAlgorithm)(s_getChecksumAlgorithm ??= typeof(ProjectInfo).GetProperty("ChecksumAlgorithm", BindingFlags.NonPublic | BindingFlags.Instance)!) - .GetValue(info)!; - - public async ValueTask UpdateFileContentAsync(IEnumerable changedFiles, CancellationToken cancellationToken) - { - var updatedSolution = CurrentSolution; - - var documentsToRemove = new List(); - - foreach (var (changedFile, change) in changedFiles) - { - // when a file is added we reevaluate the project: - Debug.Assert(change != ChangeKind.Add); - - var documentIds = updatedSolution.GetDocumentIdsWithFilePath(changedFile.FilePath); - if (change == ChangeKind.Delete) - { - documentsToRemove.AddRange(documentIds); - continue; - } - - foreach (var documentId in documentIds) - { - var textDocument = updatedSolution.GetDocument(documentId) - ?? updatedSolution.GetAdditionalDocument(documentId) - ?? updatedSolution.GetAnalyzerConfigDocument(documentId); - - if (textDocument == null) - { - _logger.LogDebug("Could not find document with path '{FilePath}' in the workspace.", changedFile.FilePath); - continue; - } - - var project = updatedSolution.GetProject(documentId.ProjectId); - Debug.Assert(project?.FilePath != null); - - var oldText = await textDocument.GetTextAsync(cancellationToken); - Debug.Assert(oldText.Encoding != null); - - var newText = await GetSourceTextAsync(changedFile.FilePath, oldText.Encoding, oldText.ChecksumAlgorithm, cancellationToken); - - _logger.LogDebug("Updating document text of '{FilePath}'.", changedFile.FilePath); - - updatedSolution = textDocument switch - { - Document document => document.WithText(newText).Project.Solution, - AdditionalDocument ad => updatedSolution.WithAdditionalDocumentText(textDocument.Id, newText, PreservationMode.PreserveValue), - AnalyzerConfigDocument acd => updatedSolution.WithAnalyzerConfigDocumentText(textDocument.Id, newText, PreservationMode.PreserveValue), - _ => throw new InvalidOperationException() - }; - } - } - - updatedSolution = RemoveDocuments(updatedSolution, documentsToRemove); - - await UpdateSolutionAsync(updatedSolution, operationDisplayName: "document update", cancellationToken); - } - - private static Solution RemoveDocuments(Solution solution, IEnumerable ids) - => solution - .RemoveDocuments([.. ids.Where(id => solution.GetDocument(id) != null)]) - .RemoveAdditionalDocuments([.. ids.Where(id => solution.GetAdditionalDocument(id) != null)]) - .RemoveAnalyzerConfigDocuments([.. ids.Where(id => solution.GetAnalyzerConfigDocument(id) != null)]); - - private static async ValueTask GetSourceTextAsync(string filePath, Encoding encoding, SourceHashAlgorithm checksumAlgorithm, CancellationToken cancellationToken) - { - var zeroLengthRetryPerformed = false; - for (var attemptIndex = 0; attemptIndex < 6; attemptIndex++) - { - try - { - // File.OpenRead opens the file with FileShare.Read. This may prevent IDEs from saving file - // contents to disk - SourceText sourceText; - using (var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - { - sourceText = SourceText.From(stream, encoding, checksumAlgorithm); - } - - if (!zeroLengthRetryPerformed && sourceText.Length == 0) - { - zeroLengthRetryPerformed = true; - - // VSCode (on Windows) will sometimes perform two separate writes when updating a file on disk. - // In the first update, it clears the file contents, and in the second, it writes the intended - // content. - // It's atypical that a file being watched for hot reload would be empty. We'll use this as a - // hueristic to identify this case and perform an additional retry reading the file after a delay. - await Task.Delay(20, cancellationToken); - - using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - sourceText = SourceText.From(stream, encoding, checksumAlgorithm); - } - - return sourceText; - } - catch (IOException) when (attemptIndex < 5) - { - await Task.Delay(20 * (attemptIndex + 1), cancellationToken); - } - } - - Debug.Fail("This shouldn't happen."); - return null; - } - - private Task UpdateSolutionAsync(Solution newSolution, string operationDisplayName, CancellationToken cancellationToken) - => ReportSolutionFilesAsync(SetCurrentSolution(newSolution), Interlocked.Increment(ref _solutionUpdateId), operationDisplayName, cancellationToken); - - private async Task ReportSolutionFilesAsync(Solution solution, int updateId, string operationDisplayName, CancellationToken cancellationToken) - { - _logger.LogDebug("Solution after {Operation}: v{Version}", operationDisplayName, updateId); - - if (!_logger.IsEnabled(LogLevel.Trace)) - { - return; - } - - foreach (var project in solution.Projects) - { - _logger.LogDebug(" Project: {Path}", project.FilePath); - - foreach (var document in project.Documents) - { - await InspectDocumentAsync(document, "Document"); - } - - foreach (var document in project.AdditionalDocuments) - { - await InspectDocumentAsync(document, "Additional"); - } - - foreach (var document in project.AnalyzerConfigDocuments) - { - await InspectDocumentAsync(document, "Config"); - } - } - - async ValueTask InspectDocumentAsync(TextDocument document, string kind) - { - var text = await document.GetTextAsync(cancellationToken); - _logger.LogDebug(" {Kind}: {FilePath} [{Checksum}]", kind, document.FilePath, Convert.ToBase64String(text.GetChecksum().ToArray())); - } - } -} diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index 46e69aade1ab..3ed141cd9765 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -42,7 +42,7 @@ public async Task ReferenceOutputAssembly_False() var handler = new CompilationHandler(context); - await handler.Workspace.UpdateProjectConeAsync(hostProject, CancellationToken.None); + await handler.UpdateProjectConeAsync(projectGraph, hostProject, CancellationToken.None); // all projects are present AssertEx.SequenceEqual(["Host", "Lib2", "Lib", "A", "B"], handler.Workspace.CurrentSolution.Projects.Select(p => p.Name)); From 50bc965c102d6901ba8c3854678915fe700f8555 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Tue, 27 Jan 2026 15:34:16 -0800 Subject: [PATCH 2/4] Local config --- NuGet.config | 1 + eng/Version.Details.props | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/NuGet.config b/NuGet.config index ff0d29fb990d..9486b1f3e749 100644 --- a/NuGet.config +++ b/NuGet.config @@ -35,6 +35,7 @@ + diff --git a/eng/Version.Details.props b/eng/Version.Details.props index 507c09944507..c7e544f5bf2b 100644 --- a/eng/Version.Details.props +++ b/eng/Version.Details.props @@ -11,18 +11,18 @@ This file should be imported by eng/Versions.props 18.3.0-preview-26076-108 7.3.0-preview.1.7708 10.0.300-alpha.26076.108 - 5.3.0-2.25610.11 - 5.3.0-2.25610.11 - 5.3.0-2.25610.11 - 5.3.0-2.25610.11 - 5.3.0-2.25610.11 - 5.3.0-2.25610.11 - 5.3.0-2.25610.11 - 5.3.0-2.25610.11 - 5.3.0-2.25610.11 + 5.4.0-dev + 5.4.0-dev + 5.4.0-dev + 5.4.0-dev + 5.4.0-dev + 5.4.0-dev + 5.4.0-dev + 5.4.0-dev + 5.4.0-dev 10.0.0-preview.26076.108 - 5.3.0-2.25610.11 - 5.3.0-2.25610.11 + 5.4.0-dev + 5.4.0-dev 10.0.0-beta.26076.108 10.0.0-beta.26076.108 10.0.0-beta.26076.108 @@ -32,8 +32,8 @@ This file should be imported by eng/Versions.props 10.0.0-beta.26076.108 10.0.0-beta.26076.108 15.2.300-servicing.26076.108 - 5.3.0-2.25610.11 - 5.3.0-2.25610.11 + 5.4.0-dev + 5.4.0-dev 10.0.0-preview.7.25377.103 10.0.0-preview.26076.108 18.3.0-release-26076-108 From f7153512c6ba38e0ab7345ade1b11bb5c6521529 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Tue, 20 Jan 2026 12:46:37 -0800 Subject: [PATCH 3/4] Guard against multiple concurrent build invocations --- .../{BuildReporter.cs => BuildManager.cs} | 15 +++++++++--- .../Watch/Build/EvaluationResult.cs | 10 ++++---- .../Watch/HotReload/CompilationHandler.cs | 4 ++-- .../Watch/HotReload/HotReloadDotNetWatcher.cs | 23 +++++++++++-------- src/BuiltInTools/dotnet-watch/Program.cs | 2 +- .../dotnet-watch/Watch/BuildEvaluator.cs | 2 +- .../Watch/MsBuildFileSetFactory.cs | 8 +++---- .../Build/EvaluationTests.cs | 6 ++--- .../TestUtilities/MockFileSetFactory.cs | 2 +- 9 files changed, 42 insertions(+), 30 deletions(-) rename src/BuiltInTools/Watch/Build/{BuildReporter.cs => BuildManager.cs} (81%) diff --git a/src/BuiltInTools/Watch/Build/BuildReporter.cs b/src/BuiltInTools/Watch/Build/BuildManager.cs similarity index 81% rename from src/BuiltInTools/Watch/Build/BuildReporter.cs rename to src/BuiltInTools/Watch/Build/BuildManager.cs index 6410476dc3c8..f24d6fee4609 100644 --- a/src/BuiltInTools/Watch/Build/BuildReporter.cs +++ b/src/BuiltInTools/Watch/Build/BuildManager.cs @@ -11,13 +11,21 @@ namespace Microsoft.DotNet.Watch; -internal sealed class BuildReporter(ILogger logger, GlobalOptions options, EnvironmentOptions environmentOptions) +internal sealed class BuildManager(ILogger logger, GlobalOptions options, EnvironmentOptions environmentOptions) { + /// + /// Semaphore that ensures we only start one build build at a time per process, which is required by MSBuild. + /// + private static readonly SemaphoreSlim s_buildSemaphore = new(initialCount: 1); + public ILogger Logger => logger; public EnvironmentOptions EnvironmentOptions => environmentOptions; - public Loggers GetLoggers(string projectPath, string operationName) - => new(logger, environmentOptions.GetBinLogPath(projectPath, operationName, options)); + public async ValueTask StartBuildAsync(string projectPath, string operationName, CancellationToken cancellationToken) + { + await s_buildSemaphore.WaitAsync(cancellationToken); + return new(logger, environmentOptions.GetBinLogPath(projectPath, operationName, options)); + } public void ReportWatchedFiles(Dictionary fileItems) { @@ -52,6 +60,7 @@ public sealed class Loggers(ILogger logger, string? binLogPath) : IEnumerable GetGlobalBuildOptions(IEnumera /// /// Loads project graph and performs design-time build. /// - public static EvaluationResult? TryCreate( + public static async ValueTask TryCreateAsync( ProjectGraphFactory factory, string rootProjectPath, ILogger logger, @@ -73,7 +73,7 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera bool restore, CancellationToken cancellationToken) { - var buildReporter = new BuildReporter(logger, options, environmentOptions); + var buildManager = new BuildManager(logger, options, environmentOptions); var projectGraph = factory.TryLoadProjectGraph( rootProjectPath, @@ -90,7 +90,7 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera if (restore) { - using (var loggers = buildReporter.GetLoggers(rootNode.ProjectInstance.FullPath, "Restore")) + using (var loggers = await buildManager.StartBuildAsync(rootNode.ProjectInstance.FullPath, "Restore", cancellationToken)) { if (!rootNode.ProjectInstance.Build([TargetNames.Restore], loggers)) { @@ -127,7 +127,7 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera continue; } - using (var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "DesignTimeBuild")) + using (var loggers = await buildManager.StartBuildAsync(projectInstance.FullPath, "DesignTimeBuild", cancellationToken)) { if (!projectInstance.Build(targets, loggers)) { @@ -198,7 +198,7 @@ void AddFile(string relativePath, string? staticWebAssetRelativeUrl) } } - buildReporter.ReportWatchedFiles(fileItems); + buildManager.ReportWatchedFiles(fileItems); return new EvaluationResult(projectGraph, restoredProjectInstances, fileItems, staticWebAssetManifests); } diff --git a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs index ba8c6de821c5..4b162ba2c81e 100644 --- a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs @@ -713,14 +713,14 @@ public async ValueTask HandleStaticAssetChangesAsync( HashSet? failedApplicationProjectInstances = null; if (projectInstancesToRegenerate.Count > 0) { - var buildReporter = new BuildReporter(_context.BuildLogger, _context.Options, _context.EnvironmentOptions); + var buildManager = new BuildManager(_context.BuildLogger, _context.Options, _context.EnvironmentOptions); // Note: MSBuild only allows one build at a time in a process. foreach (var projectInstance in projectInstancesToRegenerate) { Logger.LogDebug("[{Project}] Regenerating scoped CSS bundle.", projectInstance.GetDisplayName()); - using var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "ScopedCss"); + using var loggers = await buildManager.StartBuildAsync(projectInstance.FullPath, "ScopedCss", cancellationToken); // Deep copy so that we don't pollute the project graph: if (!projectInstance.DeepCopy().Build(s_targets, loggers)) diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs index 8b5f27544f23..689936aaf50f 100644 --- a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -370,7 +370,7 @@ void FileChangedCallback(ChangedPath change) // Deploy dependencies after rebuilding and before restarting. if (!projectsToRedeploy.IsEmpty) { - DeployProjectDependencies(evaluationResult.RestoredProjectInstances, projectsToRedeploy, iterationCancellationToken); + await DeployProjectDependenciesAsync(evaluationResult.RestoredProjectInstances, projectsToRedeploy, iterationCancellationToken); _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, projectsToRedeploy.Length); } @@ -651,10 +651,10 @@ private static bool MatchesStaticWebAssetFilePattern(EvaluationResult evaluation return false; } - private void DeployProjectDependencies(ImmutableArray restoredProjectInstances, ImmutableArray projectPaths, CancellationToken cancellationToken) + private async ValueTask DeployProjectDependenciesAsync(ImmutableArray restoredProjectInstances, ImmutableArray projectPaths, CancellationToken cancellationToken) { var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer); - var buildReporter = new BuildReporter(_context.Logger, _context.Options, _context.EnvironmentOptions); + var buildManager = new BuildManager(_context.Logger, _context.Options, _context.EnvironmentOptions); var targetName = TargetNames.ReferenceCopyLocalPathsOutputGroup; foreach (var restoredProjectInstance in restoredProjectInstances) @@ -681,12 +681,15 @@ private void DeployProjectDependencies(ImmutableArray restoredP continue; } - using var loggers = buildReporter.GetLoggers(projectPath, targetName); - if (!projectInstance.Build([targetName], loggers, out var targetOutputs)) + IDictionary targetOutputs; + using (var loggers = await buildManager.StartBuildAsync(projectPath, targetName, cancellationToken)) { - _context.Logger.LogDebug("{TargetName} target failed", targetName); - loggers.ReportOutput(); - continue; + if (!projectInstance.Build([targetName], loggers, out targetOutputs)) + { + _context.Logger.LogDebug("{TargetName} target failed", targetName); + loggers.ReportOutput(); + continue; + } } var outputDir = Path.Combine(Path.GetDirectoryName(projectPath)!, relativeOutputDir); @@ -923,9 +926,9 @@ private async ValueTask EvaluateRootProjectAsync(bool restore, _context.Logger.LogInformation("Evaluating projects ..."); var stopwatch = Stopwatch.StartNew(); - var result = EvaluationResult.TryCreate( + var result = await EvaluationResult.TryCreateAsync( _designTimeBuildGraphFactory, - _context.RootProjectOptions.ProjectPath, + _context.RootProjectOptions.ProjectPath, _context.BuildLogger, _context.Options, _context.EnvironmentOptions, diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index 938865c9d367..f5d14fb1a79a 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -251,7 +251,7 @@ private async Task ListFilesAsync(ProcessRunner processRunner, Cancellation rootProjectOptions.ProjectPath, rootProjectOptions.BuildArguments, processRunner, - new BuildReporter(buildLogger, options.GlobalOptions, environmentOptions)); + new BuildManager(buildLogger, options.GlobalOptions, environmentOptions)); if (await fileSetFactory.TryCreateAsync(requireProjectGraph: null, cancellationToken) is not { } evaluationResult) { diff --git a/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs b/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs index 756abbda6bc4..37c85cae437a 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs @@ -40,7 +40,7 @@ protected virtual MSBuildFileSetFactory CreateMSBuildFileSetFactory() _context.RootProjectOptions.ProjectPath, _context.RootProjectOptions.BuildArguments, _context.ProcessRunner, - new BuildReporter(_context.BuildLogger, _context.Options, _context.EnvironmentOptions)); + new BuildManager(_context.BuildLogger, _context.Options, _context.EnvironmentOptions)); public IReadOnlyList GetProcessArguments(int iteration) { diff --git a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs index 52f7f6d8985b..d420df6a9aba 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs @@ -22,14 +22,14 @@ internal class MSBuildFileSetFactory( string rootProjectFile, IEnumerable buildArguments, ProcessRunner processRunner, - BuildReporter buildReporter) + BuildManager buildManager) { private const string TargetName = "GenerateWatchList"; private const string WatchTargetsFileName = "DotNetWatch.targets"; public string RootProjectFile => rootProjectFile; - private EnvironmentOptions EnvironmentOptions => buildReporter.EnvironmentOptions; - private ILogger Logger => buildReporter.Logger; + private EnvironmentOptions EnvironmentOptions => buildManager.EnvironmentOptions; + private ILogger Logger => buildManager.Logger; private readonly ProjectGraphFactory _buildGraphFactory = new( globalOptions: BuildUtilities.ParseBuildProperties(buildArguments).ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value)); @@ -120,7 +120,7 @@ void AddFile(string filePath, string? staticWebAssetPath) } } - buildReporter.ReportWatchedFiles(fileItems); + buildManager.ReportWatchedFiles(fileItems); #if DEBUG Debug.Assert(fileItems.Values.All(f => Path.IsPathRooted(f.FilePath)), "All files should be rooted paths"); #endif diff --git a/test/dotnet-watch.Tests/Build/EvaluationTests.cs b/test/dotnet-watch.Tests/Build/EvaluationTests.cs index 2f8d127af74a..8695cc3736f4 100644 --- a/test/dotnet-watch.Tests/Build/EvaluationTests.cs +++ b/test/dotnet-watch.Tests/Build/EvaluationTests.cs @@ -444,7 +444,7 @@ public async Task ProjectReferences_Graph() var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDirectory, muxerPath: MuxerPath); var processRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero); - var buildReporter = new BuildReporter(_logger, new GlobalOptions(), options); + var buildReporter = new BuildManager(_logger, new GlobalOptions(), options); var filesetFactory = new MSBuildFileSetFactory(projectA, buildArguments: ["/p:_DotNetWatchTraceOutput=true"], processRunner, buildReporter); @@ -509,7 +509,7 @@ public async Task MsbuildOutput() var options = TestOptions.GetEnvironmentOptions(workingDirectory: Path.GetDirectoryName(project1Path)!, muxerPath: MuxerPath); var processRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero); - var buildReporter = new BuildReporter(_logger, new GlobalOptions(), options); + var buildReporter = new BuildManager(_logger, new GlobalOptions(), options); var factory = new MSBuildFileSetFactory(project1Path, buildArguments: [], processRunner, buildReporter); var result = await factory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); @@ -548,7 +548,7 @@ async Task VerifyTargetsEvaluation() var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDir, muxerPath: MuxerPath) with { TestOutput = testDir }; var processRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero); var buildArguments = targetFramework != null ? new[] { "/p:TargetFramework=" + targetFramework } : []; - var buildReporter = new BuildReporter(_logger, new GlobalOptions(), options); + var buildReporter = new BuildManager(_logger, new GlobalOptions(), options); var factory = new MSBuildFileSetFactory(rootProjectPath, buildArguments, processRunner, buildReporter); var targetsResult = await factory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); Assert.NotNull(targetsResult); diff --git a/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs b/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs index f5d976522af9..8ba3b3580c90 100644 --- a/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs +++ b/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs @@ -9,7 +9,7 @@ internal class MockFileSetFactory() : MSBuildFileSetFactory( rootProjectFile: "test.csproj", buildArguments: [], new ProcessRunner(processCleanupTimeout: TimeSpan.Zero), - new BuildReporter(NullLogger.Instance, new GlobalOptions(), TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet") is var options ? options : options)) + new BuildManager(NullLogger.Instance, new GlobalOptions(), TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet") is var options ? options : options)) { public Func? TryCreateImpl; From ab55cbfe7478f52a3827aa3d755895f050f30702 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 15 Dec 2025 16:51:59 -0800 Subject: [PATCH 4/4] Auto-restart when project does not support Hot Reload --- .../HotReloadClient/DefaultHotReloadClient.cs | 4 +- .../HotReloadClient/HotReloadClients.cs | 72 +++++--- .../HotReloadClient/StaticAsset.cs | 14 ++ .../AppModels/BlazorWebAssemblyAppModel.cs | 6 +- .../BlazorWebAssemblyHostedAppModel.cs | 15 +- .../Watch/AppModels/DefaultAppModel.cs | 9 +- .../Watch/AppModels/HotReloadAppModel.cs | 40 ++++- .../Watch/AppModels/WebApplicationAppModel.cs | 37 ++-- .../Watch/AppModels/WebServerAppModel.cs | 7 +- src/BuiltInTools/Watch/Build/BuildNames.cs | 3 + .../Watch/HotReload/CompilationHandler.cs | 169 +++++++++++------- .../Watch/HotReload/HotReloadDotNetWatcher.cs | 153 +++++++--------- .../Watch/HotReload/HotReloadEventSource.cs | 31 ---- .../HotReloadProjectUpdatesBuilder.cs | 17 ++ .../Watch/Process/ProjectLauncher.cs | 20 +-- .../Watch/Process/RunningProject.cs | 28 +-- src/BuiltInTools/Watch/UI/IReporter.cs | 23 +-- src/BuiltInTools/dotnet-watch/Program.cs | 1 - .../HotReloadClientTests.cs | 2 +- .../Server/Properties/launchSettings.json | 2 +- .../Properties/launchSettings.json | 2 +- .../Properties/launchSettings.json | 2 +- .../Properties/launchSettings.json | 2 +- .../Properties/launchSettings.json | 2 +- .../HotReload/ApplyDeltaTests.cs | 60 +++++-- .../HotReload/CompilationHandlerTests.cs | 1 + .../HotReload/RuntimeProcessLauncherTests.cs | 4 +- 27 files changed, 404 insertions(+), 322 deletions(-) create mode 100644 src/BuiltInTools/HotReloadClient/StaticAsset.cs delete mode 100644 src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs create mode 100644 src/BuiltInTools/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs diff --git a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs index 1642f63f8b82..7792d925bbac 100644 --- a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs @@ -20,7 +20,7 @@ namespace Microsoft.DotNet.HotReload { - internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger, string startupHookPath, bool enableStaticAssetUpdates) + internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger, string startupHookPath, bool handlesStaticAssetUpdates) : HotReloadClient(logger, agentLogger) { private readonly string _namedPipeName = Guid.NewGuid().ToString("N"); @@ -225,7 +225,7 @@ static ImmutableArray ToRuntimeUpdates(IEnumerable> ApplyStaticAssetUpdatesAsync(ImmutableArray updates, CancellationToken processExitedCancellationToken, CancellationToken cancellationToken) { - if (!enableStaticAssetUpdates) + if (!handlesStaticAssetUpdates) { // The client has no concept of static assets. return Task.FromResult(true); diff --git a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs index 1b02eac9de48..1400a43c7e09 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -16,13 +17,25 @@ namespace Microsoft.DotNet.HotReload; -internal sealed class HotReloadClients(ImmutableArray<(HotReloadClient client, string name)> clients, AbstractBrowserRefreshServer? browserRefreshServer) : IDisposable +/// +/// Facilitates Hot Reload updates across multiple clients/processes. +/// +/// +/// Clients that handle managed updates and static asset updates if is false. +/// +/// +/// Browser refresh server used to communicate managed code update status and errors to the browser, +/// and to apply static asset updates if is true. +/// +/// +/// True to use to apply static asset updates (if available). +/// False to use the to apply static asset updates. +/// +internal sealed class HotReloadClients( + ImmutableArray<(HotReloadClient client, string name)> clients, + AbstractBrowserRefreshServer? browserRefreshServer, + bool useRefreshServerToApplyStaticAssets) : IDisposable { - public HotReloadClients(HotReloadClient client, AbstractBrowserRefreshServer? browserRefreshServer) - : this([(client, "")], browserRefreshServer) - { - } - /// /// Disposes all clients. Can occur unexpectedly whenever the process exits. /// @@ -34,6 +47,16 @@ public void Dispose() } } + /// + /// True if Hot Reload is implemented via managed agents. + /// The update itself might not be managed code update, it may be a static asset update implemented via a managed agent. + /// + public bool IsManagedAgentSupported + => !clients.IsEmpty; + + public bool UseRefreshServerToApplyStaticAssets + => useRefreshServerToApplyStaticAssets; + public AbstractBrowserRefreshServer? BrowserRefreshServer => browserRefreshServer; @@ -59,18 +82,6 @@ public event Action OnRuntimeRudeEdit } } - /// - /// All clients share the same loggers. - /// - public ILogger ClientLogger - => clients.First().client.Logger; - - /// - /// All clients share the same loggers. - /// - public ILogger AgentLogger - => clients.First().client.AgentLogger; - internal void ConfigureLaunchEnvironment(IDictionary environmentBuilder) { foreach (var (client, _) in clients) @@ -99,6 +110,12 @@ internal async ValueTask WaitForConnectionEstablishedAsync(CancellationToken can /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) { + if (!IsManagedAgentSupported) + { + // empty capabilities will cause rude edit ENC0097: NotSupportedByRuntime. + return []; + } + if (clients is [var (singleClient, _)]) { return await singleClient.GetUpdateCapabilitiesAsync(cancellationToken); @@ -114,6 +131,9 @@ public async ValueTask> GetUpdateCapabilitiesAsync(Cancel /// Cancellation token. The cancellation should trigger on process terminatation. public async Task ApplyManagedCodeUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) { + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + // Apply to all processes. // The module the change is for does not need to be loaded to any of the processes, yet we still consider it successful if the application does not fail. // In each process we store the deltas for application when/if the module is loaded to the process later. @@ -137,6 +157,9 @@ async Task CompleteApplyOperationAsync() /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellationToken) { + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + if (clients is [var (singleClient, _)]) { await singleClient.InitialUpdatesAppliedAsync(cancellationToken); @@ -150,23 +173,26 @@ public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellation /// Cancellation token. The cancellation should trigger on process terminatation. public async Task ApplyStaticAssetUpdatesAsync(IEnumerable assets, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) { - if (browserRefreshServer != null) + if (useRefreshServerToApplyStaticAssets) { + Debug.Assert(browserRefreshServer != null); return browserRefreshServer.UpdateStaticAssetsAsync(assets.Select(static a => a.RelativeUrl), applyOperationCancellationToken).AsTask(); } + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + var updates = new List(); foreach (var asset in assets) { try { - ClientLogger.LogDebug("Loading asset '{Url}' from '{Path}'.", asset.RelativeUrl, asset.FilePath); updates.Add(await HotReloadStaticAssetUpdate.CreateAsync(asset, cancellationToken)); } catch (Exception e) when (e is not OperationCanceledException) { - ClientLogger.LogError("Failed to read file {FilePath}: {Message}", asset.FilePath, e.Message); + clients.First().client.Logger.LogError("Failed to read file {FilePath}: {Message}", asset.FilePath, e.Message); continue; } } @@ -177,6 +203,10 @@ public async Task ApplyStaticAssetUpdatesAsync(IEnumerable /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask ApplyStaticAssetUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) { + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + Debug.Assert(!useRefreshServerToApplyStaticAssets); + var applyTasks = await Task.WhenAll(clients.Select(c => c.client.ApplyStaticAssetUpdatesAsync(updates, applyOperationCancellationToken, cancellationToken))); return Task.WhenAll(applyTasks); diff --git a/src/BuiltInTools/HotReloadClient/StaticAsset.cs b/src/BuiltInTools/HotReloadClient/StaticAsset.cs new file mode 100644 index 000000000000..74cf7d1e096d --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/StaticAsset.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct StaticAsset(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject) +{ + public string FilePath => filePath; + public string RelativeUrl => relativeUrl; + public string AssemblyName => assemblyName; + public bool IsApplicationProject => isApplicationProject; +} diff --git a/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyAppModel.cs b/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyAppModel.cs index ea3480f979f6..ef0c112b98fb 100644 --- a/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyAppModel.cs @@ -17,11 +17,11 @@ internal sealed class BlazorWebAssemblyAppModel(DotNetWatchContext context, Proj { public override ProjectGraphNode LaunchingProject => clientProject; - public override bool RequiresBrowserRefresh => true; + public override bool ManagedHotReloadRequiresBrowserRefresh => true; - protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + protected override ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) { Debug.Assert(browserRefreshServer != null); - return new(CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), browserRefreshServer); + return [(CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "")]; } } diff --git a/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs b/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs index 12108762305b..940a9d0668ec 100644 --- a/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs @@ -19,17 +19,16 @@ internal sealed class BlazorWebAssemblyHostedAppModel(DotNetWatchContext context { public override ProjectGraphNode LaunchingProject => serverProject; - public override bool RequiresBrowserRefresh => true; + public override bool ManagedHotReloadRequiresBrowserRefresh => true; - protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + protected override ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) { Debug.Assert(browserRefreshServer != null); - return new( - [ - (CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "client"), - (new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), enableStaticAssetUpdates: false), "host") - ], - browserRefreshServer); + return + [ + (CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "client"), + (new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), handlesStaticAssetUpdates: false), "host") + ]; } } diff --git a/src/BuiltInTools/Watch/AppModels/DefaultAppModel.cs b/src/BuiltInTools/Watch/AppModels/DefaultAppModel.cs index 300236d7250a..ca04de711594 100644 --- a/src/BuiltInTools/Watch/AppModels/DefaultAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/DefaultAppModel.cs @@ -12,6 +12,11 @@ namespace Microsoft.DotNet.Watch; /// internal sealed class DefaultAppModel(ProjectGraphNode project) : HotReloadAppModel { - public override ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) - => new(new HotReloadClients(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(project), enableStaticAssetUpdates: true), browserRefreshServer: null)); + public override ValueTask CreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) + => new(new HotReloadClients( + clients: IsManagedAgentSupported(project, clientLogger) + ? [(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(project), handlesStaticAssetUpdates: true), "")] + : [], + browserRefreshServer: null, + useRefreshServerToApplyStaticAssets: false)); } diff --git a/src/BuiltInTools/Watch/AppModels/HotReloadAppModel.cs b/src/BuiltInTools/Watch/AppModels/HotReloadAppModel.cs index 7a205a8d1fce..a6668d57714a 100644 --- a/src/BuiltInTools/Watch/AppModels/HotReloadAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/HotReloadAppModel.cs @@ -9,7 +9,7 @@ namespace Microsoft.DotNet.Watch; internal abstract partial class HotReloadAppModel() { - public abstract ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken); + public abstract ValueTask CreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken); protected static string GetInjectedAssemblyPath(string targetFramework, string assemblyName) => Path.Combine(Path.GetDirectoryName(typeof(HotReloadAppModel).Assembly.Location)!, "hotreload", targetFramework, assemblyName + ".dll"); @@ -45,4 +45,42 @@ public static HotReloadAppModel InferFromProject(DotNetWatchContext context, Pro context.Logger.Log(MessageDescriptor.ApplicationKind_Default); return new DefaultAppModel(projectNode); } + + /// + /// True if a managed code agent can be injected into the target process. + /// The agent is injected either via dotnet startup hook, or via web server middleware for WASM clients. + /// + internal static bool IsManagedAgentSupported(ProjectGraphNode project, ILogger logger) + { + if (!project.IsNetCoreApp(Versions.Version6_0)) + { + LogWarning("target framework is older than 6.0"); + return false; + } + + // If property is not specified startup hook is enabled: + // https://github.com/dotnet/runtime/blob/4b0b7238ba021b610d3963313b4471517108d2bc/src/libraries/System.Private.CoreLib/src/System/StartupHookProvider.cs#L22 + // Startup hooks are not used for WASM projects. + // + // TODO: Remove once implemented: https://github.com/dotnet/runtime/issues/123778 + if (!project.ProjectInstance.GetBooleanPropertyValue(PropertyNames.StartupHookSupport, defaultValue: true) && + !project.GetCapabilities().Contains(ProjectCapability.WebAssembly)) + { + // Report which property is causing lack of support for startup hooks: + var (propertyName, propertyValue) = + project.ProjectInstance.GetBooleanPropertyValue(PropertyNames.PublishAot) + ? (PropertyNames.PublishAot, true) + : project.ProjectInstance.GetBooleanPropertyValue(PropertyNames.PublishTrimmed) + ? (PropertyNames.PublishTrimmed, true) + : (PropertyNames.StartupHookSupport, false); + + LogWarning(string.Format("'{0}' property is '{1}'", propertyName, propertyValue)); + return false; + } + + return true; + + void LogWarning(string reason) + => logger.Log(MessageDescriptor.ProjectDoesNotSupportHotReload, reason); + } } diff --git a/src/BuiltInTools/Watch/AppModels/WebApplicationAppModel.cs b/src/BuiltInTools/Watch/AppModels/WebApplicationAppModel.cs index 0f1fbb74d5d8..2460d27a79e7 100644 --- a/src/BuiltInTools/Watch/AppModels/WebApplicationAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/WebApplicationAppModel.cs @@ -16,25 +16,24 @@ internal abstract class WebApplicationAppModel(DotNetWatchContext context) : Hot public DotNetWatchContext Context => context; - public abstract bool RequiresBrowserRefresh { get; } + public abstract bool ManagedHotReloadRequiresBrowserRefresh { get; } /// /// Project that's used for launching the application. /// public abstract ProjectGraphNode LaunchingProject { get; } - protected abstract HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer); + protected abstract ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer); - public async sealed override ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) + public async sealed override ValueTask CreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) { var browserRefreshServer = await context.BrowserRefreshServerFactory.GetOrCreateBrowserRefreshServerAsync(LaunchingProject, this, cancellationToken); - if (RequiresBrowserRefresh && browserRefreshServer == null) - { - // Error has been reported - return null; - } - return CreateClients(clientLogger, agentLogger, browserRefreshServer); + var managedClients = (!ManagedHotReloadRequiresBrowserRefresh || browserRefreshServer != null) && IsManagedAgentSupported(LaunchingProject, clientLogger) + ? CreateManagedClients(clientLogger, agentLogger, browserRefreshServer) + : []; + + return new HotReloadClients(managedClients, browserRefreshServer, useRefreshServerToApplyStaticAssets: true); } protected WebAssemblyHotReloadClient CreateWebAssemblyClient(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer browserRefreshServer, ProjectGraphNode clientProject) @@ -71,13 +70,29 @@ public bool IsServerSupported(ProjectGraphNode projectNode, ILogger logger) { if (context.EnvironmentOptions.SuppressBrowserRefresh) { - logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable.WithLevelWhen(LogLevel.Error, RequiresBrowserRefresh), EnvironmentVariables.Names.SuppressBrowserRefresh); + if (ManagedHotReloadRequiresBrowserRefresh) + { + logger.Log(MessageDescriptor.BrowserRefreshSuppressedViaEnvironmentVariable_ApplicationWillBeRestarted, EnvironmentVariables.Names.SuppressBrowserRefresh); + } + else + { + logger.Log(MessageDescriptor.BrowserRefreshSuppressedViaEnvironmentVariable_ManualRefreshRequired, EnvironmentVariables.Names.SuppressBrowserRefresh); + } + return false; } if (!projectNode.IsNetCoreApp(minVersion: s_minimumSupportedVersion)) { - logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported.WithLevelWhen(LogLevel.Error, RequiresBrowserRefresh)); + if (ManagedHotReloadRequiresBrowserRefresh) + { + logger.Log(MessageDescriptor.BrowserRefreshNotSupportedByProjectTargetFramework_ApplicationWillBeRestarted); + } + else + { + logger.Log(MessageDescriptor.BrowserRefreshNotSupportedByProjectTargetFramework_ManualRefreshRequired); + } + return false; } diff --git a/src/BuiltInTools/Watch/AppModels/WebServerAppModel.cs b/src/BuiltInTools/Watch/AppModels/WebServerAppModel.cs index d30703b87530..ddd4fd25d586 100644 --- a/src/BuiltInTools/Watch/AppModels/WebServerAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/WebServerAppModel.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 Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -12,9 +13,9 @@ internal sealed class WebServerAppModel(DotNetWatchContext context, ProjectGraph { public override ProjectGraphNode LaunchingProject => serverProject; - public override bool RequiresBrowserRefresh + public override bool ManagedHotReloadRequiresBrowserRefresh => false; - protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) - => new(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), enableStaticAssetUpdates: true), browserRefreshServer); + protected override ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + => [(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), handlesStaticAssetUpdates: true), "")]; } diff --git a/src/BuiltInTools/Watch/Build/BuildNames.cs b/src/BuiltInTools/Watch/Build/BuildNames.cs index 0b2b8d0ccd00..6114a8e28e69 100644 --- a/src/BuiltInTools/Watch/Build/BuildNames.cs +++ b/src/BuiltInTools/Watch/Build/BuildNames.cs @@ -22,6 +22,9 @@ internal static class PropertyNames public const string DesignTimeBuild = nameof(DesignTimeBuild); public const string SkipCompilerExecution = nameof(SkipCompilerExecution); public const string ProvideCommandLineArgs = nameof(ProvideCommandLineArgs); + public const string StartupHookSupport = nameof(StartupHookSupport); + public const string PublishTrimmed = nameof(PublishTrimmed); + public const string PublishAot = nameof(PublishAot); } internal static class ItemNames diff --git a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs index 4b162ba2c81e..410e136dc7b2 100644 --- a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs @@ -48,8 +48,7 @@ internal sealed class CompilationHandler : IDisposable public CompilationHandler(DotNetWatchContext context) { _context = context; - _processRunner = processRunner; - Workspace = new HotReloadMSBuildWorkspace(logger, projectFile => (instances: _projectInstances.GetValueOrDefault(projectFile, []), project: null)); + Workspace = new HotReloadMSBuildWorkspace(context.Logger, projectFile => (instances: _projectInstances.GetValueOrDefault(projectFile, []), project: null)); _hotReloadService = new HotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities())); } @@ -111,6 +110,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) ProjectGraphNode projectNode, ProjectOptions projectOptions, HotReloadClients clients, + ILogger clientLogger, ProcessSpec processSpec, RestartOperation restartOperation, CancellationTokenSource processTerminationSource, @@ -149,7 +149,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) }; var launchResult = new ProcessLaunchResult(); - var runningProcess = _context.ProcessRunner.RunAsync(processSpec, clients.ClientLogger, launchResult, processTerminationSource.Token); + var runningProcess = _context.ProcessRunner.RunAsync(processSpec, clientLogger, launchResult, processTerminationSource.Token); if (launchResult.ProcessId == null) { // error already reported @@ -162,18 +162,19 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) { // Wait for agent to create the name pipe and send capabilities over. // the agent blocks the app execution until initial updates are applied (if any). - var capabilities = await clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationToken); + var managedCodeUpdateCapabilities = await clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationToken); var runningProject = new RunningProject( projectNode, projectOptions, clients, + clientLogger, runningProcess, launchResult.ProcessId.Value, processExitedSource: processExitedSource, processTerminationSource: processTerminationSource, restartOperation: restartOperation, - capabilities); + managedCodeUpdateCapabilities); // ownership transferred to running project: disposables.Items.Clear(); @@ -186,7 +187,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) // and apply them before adding it to running processes. // Do not block on udpates being made to other processes to avoid delaying the new process being up-to-date. var updatesToApply = _previousUpdates.Skip(appliedUpdateCount).ToImmutableArray(); - if (updatesToApply.Any()) + if (updatesToApply.Any() && clients.IsManagedAgentSupported) { await await clients.ApplyManagedCodeUpdatesAsync( ToManagedCodeUpdates(updatesToApply), @@ -223,20 +224,23 @@ await await clients.ApplyManagedCodeUpdatesAsync( } } - clients.OnRuntimeRudeEdit += (code, message) => + if (clients.IsManagedAgentSupported) { - // fire and forget: - _ = HandleRuntimeRudeEditAsync(runningProject, message, cancellationToken); - }; + clients.OnRuntimeRudeEdit += (code, message) => + { + // fire and forget: + _ = HandleRuntimeRudeEditAsync(runningProject, message, cancellationToken); + }; - // Notifies the agent that it can unblock the execution of the process: - await clients.InitialUpdatesAppliedAsync(processCommunicationCancellationToken); + // Notifies the agent that it can unblock the execution of the process: + await clients.InitialUpdatesAppliedAsync(processCommunicationCancellationToken); - // If non-empty solution is loaded into the workspace (a Hot Reload session is active): - if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution) - { - // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. - PrepareCompilations(currentSolution, projectPath, cancellationToken); + // If non-empty solution is loaded into the workspace (a Hot Reload session is active): + if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution) + { + // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. + PrepareCompilations(currentSolution, projectPath, cancellationToken); + } } return runningProject; @@ -251,7 +255,7 @@ await await clients.ApplyManagedCodeUpdatesAsync( private async Task HandleRuntimeRudeEditAsync(RunningProject runningProject, string rudeEditMessage, CancellationToken cancellationToken) { - var logger = runningProject.Clients.ClientLogger; + var logger = runningProject.ClientLogger; try { @@ -288,7 +292,7 @@ private ImmutableArray GetAggregateCapabilities() { var capabilities = _runningProjects .SelectMany(p => p.Value) - .SelectMany(p => p.Capabilities) + .SelectMany(p => p.ManagedCodeUpdateCapabilities) .Distinct(StringComparer.Ordinal) .Order() .ToImmutableArray(); @@ -310,13 +314,10 @@ private static void PrepareCompilations(Solution solution, string projectPath, C } } - public async ValueTask<( - ImmutableArray projectUpdates, - ImmutableArray projectsToRebuild, - ImmutableArray projectsToRedeploy, - ImmutableArray projectsToRestart)> HandleManagedCodeChangesAsync( - bool autoRestart, + public async ValueTask GetManagedCodeUpdatesAsync( + HotReloadProjectUpdatesBuilder builder, Func, CancellationToken, Task> restartPrompt, + bool autoRestart, CancellationToken cancellationToken) { var currentSolution = Workspace.CurrentSolution; @@ -340,7 +341,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C // changes and await the next file change. // Note: CommitUpdate/DiscardUpdate is not expected to be called. - return ([], [], [], []); + return; } var projectsToPromptForRestart = @@ -356,7 +357,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C Logger.Log(MessageDescriptor.HotReloadSuspended); await Task.Delay(-1, cancellationToken); - return ([], [], [], []); + return; } // Note: Releases locked project baseline readers, so we can rebuild any projects that need rebuilding. @@ -364,27 +365,31 @@ private static void PrepareCompilations(Solution solution, string projectPath, C DiscardPreviousUpdates(updates.ProjectsToRebuild); - var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); - var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); + builder.ManagedCodeUpdates.AddRange(updates.ProjectUpdates); + builder.ProjectsToRebuild.AddRange(updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!)); + builder.ProjectsToRedeploy.AddRange(updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!)); // Terminate all tracked processes that need to be restarted, // except for the root process, which will terminate later on. - var projectsToRestart = updates.ProjectsToRestart.IsEmpty - ? [] - : await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken); - - return (updates.ProjectUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart); + if (!updates.ProjectsToRestart.IsEmpty) + { + builder.ProjectsToRestart.AddRange(await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken)); + } } - public async ValueTask ApplyUpdatesAsync(ImmutableArray updates, Stopwatch stopwatch, CancellationToken cancellationToken) + public async ValueTask ApplyManagedCodeAndStaticAssetUpdatesAsync( + IReadOnlyList managedCodeUpdates, + IReadOnlyDictionary> staticAssetUpdates, + Stopwatch stopwatch, + CancellationToken cancellationToken) { - Debug.Assert(!updates.IsEmpty); + Debug.Assert(managedCodeUpdates is not []); ImmutableDictionary> projectsToUpdate; lock (_runningProjectsAndUpdatesGuard) { // Adding the updates makes sure that all new processes receive them before they are added to running processes. - _previousUpdates = _previousUpdates.AddRange(updates); + _previousUpdates = _previousUpdates.AddRange(managedCodeUpdates); // Capture the set of processes that do not have the currently calculated deltas yet. projectsToUpdate = _runningProjects; @@ -399,9 +404,11 @@ public async ValueTask ApplyUpdatesAsync(ImmutableArray { foreach (var runningProject in projects) { + Debug.Assert(runningProject.Clients.IsManagedAgentSupported); + // Only cancel applying updates when the process exits. Canceling disables further updates since the state of the runtime becomes unknown. var applyTask = await runningProject.Clients.ApplyManagedCodeUpdatesAsync( - ToManagedCodeUpdates(updates), + ToManagedCodeUpdates(managedCodeUpdates), applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, cancellationToken); @@ -409,17 +416,42 @@ public async ValueTask ApplyUpdatesAsync(ImmutableArray } } + // Creating apply tasks involves reading static assets from disk. Parallelize this IO. + var staticAssetApplyTaskProducers = new List>(); + + foreach (var (runningProject, assets) in staticAssetUpdates) + { + // Only cancel applying updates when the process exits. Canceling in-progress static asset update might be ok, + // but for consistency with managed code updates we only cancel when the process exits. + staticAssetApplyTaskProducers.Add(runningProject.Clients.ApplyStaticAssetUpdatesAsync( + assets, + applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, + cancellationToken)); + } + + applyTasks.AddRange(await Task.WhenAll(staticAssetApplyTaskProducers)); + // fire and forget: - _ = CompleteApplyOperationAsync(applyTasks, stopwatch, MessageDescriptor.ManagedCodeChangesApplied); + _ = CompleteApplyOperationAsync(applyTasks, stopwatch, managedCodeUpdates.Count > 0, staticAssetUpdates.Count > 0); } - private async Task CompleteApplyOperationAsync(IEnumerable applyTasks, Stopwatch stopwatch, MessageDescriptor message) + private async Task CompleteApplyOperationAsync(IEnumerable applyTasks, Stopwatch stopwatch, bool hasManagedCodeUpdates, bool hasStaticAssetUpdates) { try { await Task.WhenAll(applyTasks); - _context.Logger.Log(message, stopwatch.ElapsedMilliseconds); + var elapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + if (hasManagedCodeUpdates) + { + _context.Logger.Log(MessageDescriptor.ManagedCodeChangesApplied, elapsedMilliseconds); + } + + if (hasStaticAssetUpdates) + { + _context.Logger.Log(MessageDescriptor.StaticAssetsChangesApplied, elapsedMilliseconds); + } } catch (Exception e) { @@ -427,7 +459,7 @@ private async Task CompleteApplyOperationAsync(IEnumerable applyTasks, Sto if (e is not OperationCanceledException) { - _context.Logger.LogError("Failed to apply updates: {Exception}", e.ToString()); + _context.Logger.LogError("Failed to apply managedCodeUpdates: {Exception}", e.ToString()); } } } @@ -457,7 +489,7 @@ private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, Im break; case HotReloadService.Status.NoChangesToApply: - Logger.Log(MessageDescriptor.NoCSharpChangesToApply); + Logger.Log(MessageDescriptor.NoManagedCodeChangesToApply); break; case HotReloadService.Status.Blocked: @@ -593,11 +625,11 @@ static MessageDescriptor GetMessageDescriptor(Diagnostic diagnostic, bool verbos private static bool HasScopedCssTargets(ProjectInstance projectInstance) => s_targets.All(projectInstance.Targets.ContainsKey); - public async ValueTask HandleStaticAssetChangesAsync( + public async ValueTask GetStaticAssetUpdatesAsync( + HotReloadProjectUpdatesBuilder builder, IReadOnlyList files, ProjectNodeMap projectMap, IReadOnlyDictionary manifests, - Stopwatch stopwatch, CancellationToken cancellationToken) { var assets = new Dictionary>(); @@ -638,7 +670,7 @@ public async ValueTask HandleStaticAssetChangesAsync( foreach (var referencingProjectNode in containingProjectNode.GetAncestorsAndSelf()) { var applicationProjectInstance = referencingProjectNode.ProjectInstance; - if (!TryGetRunningProject(applicationProjectInstance.FullPath, out var runningProjects)) + if (!TryGetRunningProject(applicationProjectInstance.FullPath, out _)) { continue; } @@ -733,9 +765,6 @@ public async ValueTask HandleStaticAssetChangesAsync( } } - // Creating apply tasks involves reading static assets from disk. Parallelize this IO. - var applyTaskProducers = new List>(); - foreach (var (applicationProjectInstance, instanceAssets) in assets) { if (failedApplicationProjectInstances?.Contains(applicationProjectInstance) == true) @@ -750,19 +779,23 @@ public async ValueTask HandleStaticAssetChangesAsync( foreach (var runningProject in runningProjects) { - // Only cancel applying updates when the process exits. Canceling in-progress static asset update might be ok, - // but for consistency with managed code updates we only cancel when the process exits. - applyTaskProducers.Add(runningProject.Clients.ApplyStaticAssetUpdatesAsync( - instanceAssets.Values, - applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, - cancellationToken)); + if (!builder.StaticAssetsToUpdate.TryGetValue(runningProject, out var updatesPerRunningProject)) + { + builder.StaticAssetsToUpdate.Add(runningProject, updatesPerRunningProject = []); + } + + if (!runningProject.Clients.UseRefreshServerToApplyStaticAssets && !runningProject.Clients.IsManagedAgentSupported) + { + // Static assets are applied via managed Hot Reload agent (e.g. in MAUI Blazor app), but managed Hot Reload is not supported (e.g. startup hooks are disabled). + builder.ProjectsToRebuild.Add(runningProject.ProjectNode.ProjectInstance.FullPath); + builder.ProjectsToRestart.Add(runningProject); + } + else + { + updatesPerRunningProject.AddRange(instanceAssets.Values); + } } } - - var applyTasks = await Task.WhenAll(applyTaskProducers); - - // fire and forget: - _ = CompleteApplyOperationAsync(applyTasks, stopwatch, MessageDescriptor.StaticAssetsChangesApplied); } /// @@ -838,7 +871,7 @@ public bool TryGetRunningProject(string projectPath, out ImmutableArray> projects, Func action, CancellationToken cancellationToken) => Task.WhenAll(projects.SelectMany(entry => entry.Value).Select(project => action(project, cancellationToken))).WaitAsync(cancellationToken); - private static ImmutableArray ToManagedCodeUpdates(ImmutableArray updates) + private static ImmutableArray ToManagedCodeUpdates(IEnumerable updates) => [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))]; private static ImmutableDictionary> CreateProjectInstanceMap(ProjectGraph graph) @@ -850,7 +883,7 @@ private static ImmutableDictionary> Crea public async Task UpdateProjectConeAsync(ProjectGraph projectGraph, string projectPath, CancellationToken cancellationToken) { - _logger.LogInformation("Loading projects ..."); + Logger.LogInformation("Loading projects ..."); var stopwatch = Stopwatch.StartNew(); _projectInstances = CreateProjectInstanceMap(projectGraph); @@ -858,10 +891,10 @@ public async Task UpdateProjectConeAsync(ProjectGraph projectGraph, string proje var solution = await Workspace.UpdateProjectConeAsync(projectPath, cancellationToken); await SolutionUpdatedAsync(solution, "project update", cancellationToken); - _logger.LogInformation("Projects loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); + Logger.LogInformation("Projects loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); } - public async Task UpdateFileContentAsync(ImmutableList changedFiles, CancellationToken cancellationToken) + public async Task UpdateFileContentAsync(IReadOnlyList changedFiles, CancellationToken cancellationToken) { var solution = await Workspace.UpdateFileContentAsync(changedFiles.Select(static f => (f.Item.FilePath, f.Kind.Convert())), cancellationToken); await SolutionUpdatedAsync(solution, "document update", cancellationToken); @@ -872,16 +905,16 @@ private Task SolutionUpdatedAsync(Solution newSolution, string operationDisplayN private async Task ReportSolutionFilesAsync(Solution solution, int updateId, string operationDisplayName, CancellationToken cancellationToken) { - _logger.LogDebug("Solution after {Operation}: v{Version}", operationDisplayName, updateId); + Logger.LogDebug("Solution after {Operation}: v{Version}", operationDisplayName, updateId); - if (!_logger.IsEnabled(LogLevel.Trace)) + if (!Logger.IsEnabled(LogLevel.Trace)) { return; } foreach (var project in solution.Projects) { - _logger.LogDebug(" Project: {Path}", project.FilePath); + Logger.LogDebug(" Project: {Path}", project.FilePath); foreach (var document in project.Documents) { @@ -902,7 +935,7 @@ private async Task ReportSolutionFilesAsync(Solution solution, int updateId, str async ValueTask InspectDocumentAsync(TextDocument document, string kind) { var text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); - _logger.LogDebug(" {Kind}: {FilePath} [{Checksum}]", kind, document.FilePath, Convert.ToBase64String(text.GetChecksum().ToArray())); + Logger.LogDebug(" {Kind}: {FilePath} [{Checksum}]", kind, document.FilePath, Convert.ToBase64String(text.GetChecksum().ToArray())); } } } diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs index 689936aaf50f..c16589a3f632 100644 --- a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -153,19 +153,6 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) return; } - if (!await rootRunningProject.WaitForProcessRunningAsync(iterationCancellationToken)) - { - // Process might have exited while we were trying to communicate with it. - // Cancel the iteration, but wait for a file change before starting a new one. - iterationCancellationSource.Cancel(); - iterationCancellationSource.Token.ThrowIfCancellationRequested(); - } - - if (shutdownCancellationToken.IsCancellationRequested) - { - // Ctrl+C: - return; - } await compilationHandler.UpdateProjectConeAsync(evaluationResult.ProjectGraph, rootProjectOptions.ProjectPath, iterationCancellationToken); @@ -247,80 +234,33 @@ void FileChangedCallback(ChangedPath change) continue; } - if (!rootProjectCapabilities.Contains("SupportsHotReload")) - { - _context.Logger.LogWarning("Project '{Name}' does not support Hot Reload and must be rebuilt.", rootProject.GetDisplayName()); - - // file change already detected - waitForFileChangeBeforeRestarting = false; - iterationCancellationSource.Cancel(); - break; - } - - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.Main); + var updates = new HotReloadProjectUpdatesBuilder(); var stopwatch = Stopwatch.StartNew(); - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.StaticHandler); - await compilationHandler.HandleStaticAssetChangesAsync(changedFiles, projectMap, evaluationResult.StaticWebAssetsManifests, stopwatch, iterationCancellationToken); - HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.StaticHandler); + await compilationHandler.GetStaticAssetUpdatesAsync(updates, changedFiles, projectMap, evaluationResult.StaticWebAssetsManifests, iterationCancellationToken); - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.CompilationHandler); - - var (managedCodeUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync( - autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true, + await compilationHandler.GetManagedCodeUpdatesAsync( + updates, restartPrompt: async (projectNames, cancellationToken) => { - if (_rudeEditRestartPrompt != null) - { - // stop before waiting for user input: - stopwatch.Stop(); - - string question; - if (runtimeProcessLauncher == null) - { - question = "Do you want to restart your app?"; - } - else - { - _context.Logger.LogInformation("Affected projects:"); - - foreach (var projectName in projectNames.OrderBy(n => n)) - { - _context.Logger.LogInformation(" {ProjectName}", projectName); - } - - question = "Do you want to restart these projects?"; - } - - return await _rudeEditRestartPrompt.WaitForRestartConfirmationAsync(question, cancellationToken); - } - - _context.Logger.LogDebug("Restarting without prompt since dotnet-watch is running in non-interactive mode."); - - foreach (var projectName in projectNames) - { - _context.Logger.LogDebug(" Project to restart: '{ProjectName}'", projectName); - } - - return true; + // stop before waiting for user input: + stopwatch.Stop(); + var result = await RestartPrompt(projectNames, runtimeProcessLauncher, cancellationToken); + stopwatch.Start(); + return result; }, + autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true, iterationCancellationToken); - HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.CompilationHandler); - - stopwatch.Stop(); - - HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.Main); - // Terminate root process if it had rude edits or is non-reloadable. - if (projectsToRestart.SingleOrDefault(project => project.Options.IsRootProject) is { } rootProjectToRestart) + if (updates.ProjectsToRestart.SingleOrDefault(project => project.Options.IsRootProject) is { } rootProjectToRestart) { // Triggers rootRestartCancellationToken. waitForFileChangeBeforeRestarting = false; break; } - if (!projectsToRebuild.IsEmpty) + if (updates.ProjectsToRebuild is not []) { while (true) { @@ -333,7 +273,7 @@ void FileChangedCallback(ChangedPath change) // Build projects sequentially to avoid failed attempts to overwrite dependent project outputs. // TODO: Ideally, dotnet build would be able to build multiple projects. https://github.com/dotnet/sdk/issues/51311 var success = true; - foreach (var projectPath in projectsToRebuild) + foreach (var projectPath in updates.ProjectsToRebuild) { success = await BuildProjectAsync(projectPath, rootProjectOptions.BuildArguments, iterationCancellationToken); if (!success) @@ -362,39 +302,35 @@ void FileChangedCallback(ChangedPath change) // Changes made since last snapshot of the accumulator shouldn't be included in next Hot Reload update. // Apply them to the workspace. - _ = await CaptureChangedFilesSnapshot(projectsToRebuild); + _ = await CaptureChangedFilesSnapshot(updates.ProjectsToRebuild); - _context.Logger.Log(MessageDescriptor.ProjectsRebuilt, projectsToRebuild.Length); + _context.Logger.Log(MessageDescriptor.ProjectsRebuilt, updates.ProjectsToRebuild.Count); } // Deploy dependencies after rebuilding and before restarting. - if (!projectsToRedeploy.IsEmpty) + if (updates.ProjectsToRedeploy is not []) { - await DeployProjectDependenciesAsync(evaluationResult.RestoredProjectInstances, projectsToRedeploy, iterationCancellationToken); - _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, projectsToRedeploy.Length); + await DeployProjectDependenciesAsync(evaluationResult.RestoredProjectInstances, updates.ProjectsToRedeploy, iterationCancellationToken); + _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, updates.ProjectsToRedeploy.Count); } // Apply updates only after dependencies have been deployed, // so that updated code doesn't attempt to access the dependency before it has been deployed. - if (!managedCodeUpdates.IsEmpty) + if (updates.ManagedCodeUpdates.Count > 0 || updates.StaticAssetsToUpdate.Count > 0) { - await compilationHandler.ApplyUpdatesAsync(managedCodeUpdates, stopwatch, iterationCancellationToken); + await compilationHandler.ApplyManagedCodeAndStaticAssetUpdatesAsync(updates.ManagedCodeUpdates, updates.StaticAssetsToUpdate, stopwatch, iterationCancellationToken); } - if (!projectsToRestart.IsEmpty) + if (updates.ProjectsToRestart is not []) { await Task.WhenAll( - projectsToRestart.Select(async runningProject => - { - var newRunningProject = await runningProject.RestartOperation(shutdownCancellationToken); - _ = await newRunningProject.WaitForProcessRunningAsync(shutdownCancellationToken); - })) + updates.ProjectsToRestart.Select(async runningProject => runningProject.RestartOperation(shutdownCancellationToken))) .WaitAsync(shutdownCancellationToken); - _context.Logger.Log(MessageDescriptor.ProjectsRestarted, projectsToRestart.Length); + _context.Logger.Log(MessageDescriptor.ProjectsRestarted, updates.ProjectsToRestart.Count); } - async Task> CaptureChangedFilesSnapshot(ImmutableArray rebuiltProjects) + async Task> CaptureChangedFilesSnapshot(IReadOnlyList rebuiltProjects) { var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []); if (changedPaths is []) @@ -462,7 +398,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr _context.Logger.Log(MessageDescriptor.ReEvaluationCompleted); } - if (!rebuiltProjects.IsEmpty) + if (rebuiltProjects is not []) { // Filter changed files down to those contained in projects being rebuilt. // File changes that affect projects that are not being rebuilt will stay in the accumulator @@ -552,7 +488,40 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr } } -<<<<<<< HEAD + private async Task RestartPrompt(IEnumerable projectNames, IRuntimeProcessLauncher? runtimeProcessLauncher, CancellationToken cancellationToken) + { + if (_rudeEditRestartPrompt != null) + { + string question; + if (runtimeProcessLauncher == null) + { + question = "Do you want to restart your app?"; + } + else + { + _context.Logger.LogInformation("Affected projects:"); + + foreach (var projectName in projectNames.OrderBy(n => n)) + { + _context.Logger.LogInformation(" {ProjectName}", projectName); + } + + question = "Do you want to restart these projects?"; + } + + return await _rudeEditRestartPrompt.WaitForRestartConfirmationAsync(question, cancellationToken); + } + + _context.Logger.LogDebug("Restarting without prompt since dotnet-watch is running in non-interactive mode."); + + foreach (var projectName in projectNames) + { + _context.Logger.LogDebug(" Project to restart: '{ProjectName}'", projectName); + } + + return true; + } + private void AnalyzeFileChanges( List changedFiles, EvaluationResult evaluationResult, @@ -607,7 +576,7 @@ private static bool MatchesBuildFile(string filePath) return extension.Equals(".props", PathUtilities.OSSpecificPathComparison) || extension.Equals(".targets", PathUtilities.OSSpecificPathComparison) || extension.EndsWith("proj", PathUtilities.OSSpecificPathComparison) - || extension.Equals("projitems", PathUtilities.OSSpecificPathComparison) // shared project items + || extension.Equals(".projitems", PathUtilities.OSSpecificPathComparison) // shared project items || string.Equals(Path.GetFileName(filePath), "global.json", PathUtilities.OSSpecificPathComparison); } @@ -651,7 +620,7 @@ private static bool MatchesStaticWebAssetFilePattern(EvaluationResult evaluation return false; } - private async ValueTask DeployProjectDependenciesAsync(ImmutableArray restoredProjectInstances, ImmutableArray projectPaths, CancellationToken cancellationToken) + private async ValueTask DeployProjectDependenciesAsync(ImmutableArray restoredProjectInstances, IReadOnlyList projectPaths, CancellationToken cancellationToken) { var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer); var buildManager = new BuildManager(_context.Logger, _context.Options, _context.EnvironmentOptions); diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs b/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs deleted file mode 100644 index 4196f85ba21b..000000000000 --- a/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs +++ /dev/null @@ -1,31 +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.Diagnostics.Tracing; - -namespace Microsoft.DotNet.Watch -{ - [EventSource(Name = "HotReload")] - internal sealed class HotReloadEventSource : EventSource - { - public enum StartType - { - Main, - StaticHandler, - CompilationHandler, - } - - internal sealed class Keywords - { - public const EventKeywords Perf = (EventKeywords)1; - } - - [Event(1, Message = "Hot reload started for {0}", Level = EventLevel.Informational, Keywords = Keywords.Perf)] - public void HotReloadStart(StartType handlerType) { WriteEvent(1, handlerType); } - - [Event(2, Message = "Hot reload finished for {0}", Level = EventLevel.Informational, Keywords = Keywords.Perf)] - public void HotReloadEnd(StartType handlerType) { WriteEvent(2, handlerType); } - - public static readonly HotReloadEventSource Log = new(); - } -} diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs b/src/BuiltInTools/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs new file mode 100644 index 000000000000..a56fbe8e3696 --- /dev/null +++ b/src/BuiltInTools/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs @@ -0,0 +1,17 @@ +// 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 Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; +using Microsoft.DotNet.HotReload; + +namespace Microsoft.DotNet.Watch; + +internal sealed class HotReloadProjectUpdatesBuilder +{ + public List ManagedCodeUpdates { get; } = []; + public Dictionary> StaticAssetsToUpdate { get; } = []; + public List ProjectsToRebuild { get; } = []; + public List ProjectsToRedeploy { get; } = []; + public List ProjectsToRestart { get; } = []; +} diff --git a/src/BuiltInTools/Watch/Process/ProjectLauncher.cs b/src/BuiltInTools/Watch/Process/ProjectLauncher.cs index fb74333ebdd3..60ca18552227 100644 --- a/src/BuiltInTools/Watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/Watch/Process/ProjectLauncher.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -41,25 +42,13 @@ public EnvironmentOptions EnvironmentOptions return null; } - if (!projectNode.IsNetCoreApp(Versions.Version6_0)) - { - Logger.LogError($"Hot Reload based watching is only supported in .NET 6.0 or newer apps. Use --no-hot-reload switch or update the project's launchSettings.json to disable this feature."); - return null; - } - - var appModel = HotReloadAppModel.InferFromProject(context, projectNode); - // create loggers that include project name in messages: var projectDisplayName = projectNode.GetDisplayName(); var clientLogger = context.LoggerFactory.CreateLogger(HotReloadDotNetWatcher.ClientLogComponentName, projectDisplayName); var agentLogger = context.LoggerFactory.CreateLogger(HotReloadDotNetWatcher.AgentLogComponentName, projectDisplayName); - var clients = await appModel.TryCreateClientsAsync(clientLogger, agentLogger, cancellationToken); - if (clients == null) - { - // error already reported - return null; - } + var appModel = HotReloadAppModel.InferFromProject(context, projectNode); + var clients = await appModel.CreateClientsAsync(clientLogger, agentLogger, cancellationToken); var processSpec = new ProcessSpec { @@ -91,7 +80,7 @@ public EnvironmentOptions EnvironmentOptions environmentBuilder[EnvironmentVariables.Names.DotnetWatch] = "1"; environmentBuilder[EnvironmentVariables.Names.DotnetWatchIteration] = (Iteration + 1).ToString(CultureInfo.InvariantCulture); - if (Logger.IsEnabled(LogLevel.Trace)) + if (clients.IsManagedAgentSupported && Logger.IsEnabled(LogLevel.Trace)) { environmentBuilder[EnvironmentVariables.Names.HotReloadDeltaClientLogMessages] = (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix() + $"[{projectDisplayName}]"; @@ -109,6 +98,7 @@ public EnvironmentOptions EnvironmentOptions projectNode, projectOptions, clients, + clientLogger, processSpec, restartOperation, processTerminationSource, diff --git a/src/BuiltInTools/Watch/Process/RunningProject.cs b/src/BuiltInTools/Watch/Process/RunningProject.cs index c4d63e953a22..748f8f3c7ef3 100644 --- a/src/BuiltInTools/Watch/Process/RunningProject.cs +++ b/src/BuiltInTools/Watch/Process/RunningProject.cs @@ -15,17 +15,19 @@ internal sealed class RunningProject( ProjectGraphNode projectNode, ProjectOptions options, HotReloadClients clients, + ILogger clientLogger, Task runningProcess, int processId, CancellationTokenSource processExitedSource, CancellationTokenSource processTerminationSource, RestartOperation restartOperation, - ImmutableArray capabilities) : IDisposable + ImmutableArray managedCodeUpdateCapabilities) : IDisposable { public readonly ProjectGraphNode ProjectNode = projectNode; public readonly ProjectOptions Options = options; public readonly HotReloadClients Clients = clients; - public readonly ImmutableArray Capabilities = capabilities; + public readonly ILogger ClientLogger = clientLogger; + public readonly ImmutableArray ManagedCodeUpdateCapabilities = managedCodeUpdateCapabilities; public readonly Task RunningProcess = runningProcess; public readonly int ProcessId = processId; public readonly RestartOperation RestartOperation = restartOperation; @@ -61,26 +63,6 @@ public void Dispose() processExitedSource.Dispose(); } - /// - /// Waits for the application process to start. - /// Ensures that the build has been complete and the build outputs are available. - /// Returns false if the process has exited before the connection was established. - /// - public async ValueTask WaitForProcessRunningAsync(CancellationToken cancellationToken) - { - using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, ProcessExitedCancellationToken); - - try - { - await Clients.WaitForConnectionEstablishedAsync(processCommunicationCancellationSource.Token); - return true; - } - catch (OperationCanceledException) when (ProcessExitedCancellationToken.IsCancellationRequested) - { - return false; - } - } - /// /// Terminates the process if it hasn't terminated yet. /// @@ -126,7 +108,7 @@ public async Task CompleteApplyOperationAsync(Task applyTask) // Handle all exceptions. If one process is terminated or fails to apply changes // it shouldn't prevent applying updates to other processes. - Clients.ClientLogger.LogError("Failed to apply updates to process {Process}: {Exception}", ProcessId, e.ToString()); + ClientLogger.LogError("Failed to apply updates to process {Process}: {Exception}", ProcessId, e.ToString()); } } } diff --git a/src/BuiltInTools/Watch/UI/IReporter.cs b/src/BuiltInTools/Watch/UI/IReporter.cs index b2421ef89fa4..f73fcb349e6b 100644 --- a/src/BuiltInTools/Watch/UI/IReporter.cs +++ b/src/BuiltInTools/Watch/UI/IReporter.cs @@ -135,20 +135,6 @@ public static MessageDescriptor GetDescriptor(EventId id) public string GetMessage(params object?[] args) => Id.Id == 0 ? Format : string.Format(Format, args); - public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) - => condition && Level != level - ? this with - { - Level = level, - Emoji = level switch - { - LogLevel.Error or LogLevel.Critical => Emoji.Error, - LogLevel.Warning => Emoji.Warning, - _ => Emoji - } - } - : this; - public static readonly ImmutableDictionary ComponentEmojis = ImmutableDictionary.Empty .Add(DotNetWatchContext.DefaultLogComponentName, Emoji.Watch) .Add(DotNetWatchContext.BuildLogComponentName, Emoji.Build) @@ -189,8 +175,10 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) public static readonly MessageDescriptor ApplyUpdate_FileContentDoesNotMatchBuiltSource = Create("{0} Expected if a source file is updated that is linked to project whose build is not up-to-date.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ConfiguredToLaunchBrowser = Create("dotnet-watch is configured to launch a browser on ASP.NET Core application startup.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = Create("Using browser-refresh middleware", Emoji.Default, LogLevel.Debug); - public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable = Create("Skipping configuring browser-refresh middleware since its refresh server suppressed via environment variable {0}.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported = Create("Skipping configuring browser-refresh middleware since the target framework version is not supported. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor BrowserRefreshSuppressedViaEnvironmentVariable_ManualRefreshRequired = Create("Browser refresh is suppressed via environment variable '{0}'. To reload static assets after an update refresh browser manually.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor BrowserRefreshSuppressedViaEnvironmentVariable_ApplicationWillBeRestarted = Create("Browser refresh is suppressed via environment variable '{0}'. Application will be restarted when updated.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor BrowserRefreshNotSupportedByProjectTargetFramework_ManualRefreshRequired = Create("Browser refresh is ot supported by the project target framework. To reload static assets after an update refresh browser manually. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor BrowserRefreshNotSupportedByProjectTargetFramework_ApplicationWillBeRestarted = Create("Browser refresh is ot supported by the project target framework. Application will be restarted when updated. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, LogLevel.Warning); public static readonly MessageDescriptor UpdatingDiagnostics = Create(LogEvents.UpdatingDiagnostics, Emoji.Default); public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create(LogEvents.FailedToReceiveResponseFromConnectedBrowser, Emoji.Default); public static readonly MessageDescriptor NoBrowserConnected = Create(LogEvents.NoBrowserConnected, Emoji.Default); @@ -207,7 +195,7 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = Create("File addition triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ProjectChangeTriggeredReEvaluation = Create("Project change triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ReEvaluationCompleted = Create("Re-evaluation completed.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor NoCSharpChangesToApply = Create("No C# or Razor changes to apply.", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor NoManagedCodeChangesToApply = Create("No managed code changes to apply.", Emoji.Watch, LogLevel.Information); public static readonly MessageDescriptor Exited = Create("Exited", Emoji.Watch, LogLevel.Information); public static readonly MessageDescriptor ExitedWithUnknownErrorCode = Create("Exited with unknown error code", Emoji.Error, LogLevel.Error); public static readonly MessageDescriptor ExitedWithErrorCode = Create("Exited with error code {0}", Emoji.Error, LogLevel.Error); @@ -226,6 +214,7 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) public static readonly MessageDescriptor UnableToApplyChanges = Create("Unable to apply changes due to compilation errors.", Emoji.HotReload, LogLevel.Information); public static readonly MessageDescriptor RestartNeededToApplyChanges = Create("Restart is needed to apply the changes.", Emoji.HotReload, LogLevel.Information); public static readonly MessageDescriptor HotReloadEnabled = Create("Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor ProjectDoesNotSupportHotReload = Create("Project does not support Hot Reload: {0}. Application will be restarted when updated.", Emoji.Warning, LogLevel.Warning); public static readonly MessageDescriptor PressCtrlRToRestart = Create("Press Ctrl+R to restart.", Emoji.LightBulb, LogLevel.Information); public static readonly MessageDescriptor ApplicationKind_BlazorHosted = Create("Application kind: BlazorHosted. '{0}' references BlazorWebAssembly project '{1}'.", Emoji.Default, LogLevel.Debug); public static readonly MessageDescriptor ApplicationKind_BlazorWebAssembly = Create("Application kind: BlazorWebAssembly.", Emoji.Default, LogLevel.Debug); diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index f5d14fb1a79a..c9d3b6fb6bb9 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Runtime.Loader; using Microsoft.Build.Locator; using Microsoft.Extensions.Logging; diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs index 7bba809b7657..cd5c1d30ef00 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs @@ -19,7 +19,7 @@ public Test(ITestOutputHelper output, TestHotReloadAgent agent) { Logger = new TestLogger(output); AgentLogger = new TestLogger(output); - Client = new DefaultHotReloadClient(Logger, AgentLogger, startupHookPath: "", enableStaticAssetUpdates: true); + Client = new DefaultHotReloadClient(Logger, AgentLogger, startupHookPath: "", handlesStaticAssetUpdates: true); _cancellationSource = new CancellationTokenSource(); diff --git a/test/TestAssets/TestProjects/BlazorWasmHosted50/Server/Properties/launchSettings.json b/test/TestAssets/TestProjects/BlazorWasmHosted50/Server/Properties/launchSettings.json index b710223555e9..51d894aa4520 100644 --- a/test/TestAssets/TestProjects/BlazorWasmHosted50/Server/Properties/launchSettings.json +++ b/test/TestAssets/TestProjects/BlazorWasmHosted50/Server/Properties/launchSettings.json @@ -18,7 +18,7 @@ }, "BlazorWasmHosted50.Server": { "commandName": "Project", - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "https://localhost:5001;http://localhost:5000", diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/Properties/launchSettings.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/Properties/launchSettings.json index dc7b2f31b4b3..38b39f84b3d4 100644 --- a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/Properties/launchSettings.json +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "http": { "commandName": "Project", - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:5000", "environmentVariables": { diff --git a/test/TestAssets/TestProjects/WatchBlazorWasm/Properties/launchSettings.json b/test/TestAssets/TestProjects/WatchBlazorWasm/Properties/launchSettings.json index dc7b2f31b4b3..38b39f84b3d4 100644 --- a/test/TestAssets/TestProjects/WatchBlazorWasm/Properties/launchSettings.json +++ b/test/TestAssets/TestProjects/WatchBlazorWasm/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "http": { "commandName": "Project", - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:5000", "environmentVariables": { diff --git a/test/TestAssets/TestProjects/WatchBlazorWasmHosted/blazorhosted/Properties/launchSettings.json b/test/TestAssets/TestProjects/WatchBlazorWasmHosted/blazorhosted/Properties/launchSettings.json index dc7b2f31b4b3..38b39f84b3d4 100644 --- a/test/TestAssets/TestProjects/WatchBlazorWasmHosted/blazorhosted/Properties/launchSettings.json +++ b/test/TestAssets/TestProjects/WatchBlazorWasmHosted/blazorhosted/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "http": { "commandName": "Project", - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:5000", "environmentVariables": { diff --git a/test/TestAssets/TestProjects/WatchBrowserLaunchApp/Properties/launchSettings.json b/test/TestAssets/TestProjects/WatchBrowserLaunchApp/Properties/launchSettings.json index 8b203ef4a5b2..0f72a18452a0 100644 --- a/test/TestAssets/TestProjects/WatchBrowserLaunchApp/Properties/launchSettings.json +++ b/test/TestAssets/TestProjects/WatchBrowserLaunchApp/Properties/launchSettings.json @@ -17,7 +17,7 @@ }, "BrowserLaunchApp": { "commandName": "Project", - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 792ced5064af..d17a2343e309 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -89,7 +89,7 @@ public async Task ProjectChange_UpdateDirectoryBuildPropsThenUpdateSource() Path.Combine(testAsset.Path, "Directory.Build.props"), src => src.Replace("false", "true")); - await App.WaitForOutputLineContaining(MessageDescriptor.NoCSharpChangesToApply); + await App.WaitForOutputLineContaining(MessageDescriptor.NoManagedCodeChangesToApply); App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation); App.Process.ClearOutput(); @@ -153,7 +153,7 @@ public static void Print() await App.WaitUntilOutputContains($"{symbolName} not set"); } - [Fact(Skip = "https://github.com/dotnet/msbuild/issues/12001")] + [Fact] public async Task ProjectChange_DirectoryBuildProps_Add() { var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps") @@ -196,7 +196,7 @@ public static void Print() App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation); } - [Fact(Skip = "https://github.com/dotnet/sdk/issues/49545")] + [Fact] public async Task ProjectChange_DirectoryBuildProps_Delete() { var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps") @@ -228,7 +228,7 @@ public static void Print() Log($"Deleting {directoryBuildProps}"); File.Delete(directoryBuildProps); - await App.WaitForOutputLineContaining(MessageDescriptor.NoCSharpChangesToApply); + await App.WaitForOutputLineContaining(MessageDescriptor.NoManagedCodeChangesToApply); App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation); App.Process.ClearOutput(); @@ -442,7 +442,7 @@ public async Task AutoRestartOnRudeEdit(bool nonInteractive) await App.WaitForOutputLineContaining(MessageDescriptor.ManagedCodeChangesApplied); } - [Theory(Skip = "https://github.com/dotnet/sdk/issues/51469")] + [Theory] [CombinatorialData] public async Task AutoRestartOnRuntimeRudeEdit(bool nonInteractive) { @@ -613,6 +613,36 @@ public async Task BaselineCompilationError() await App.WaitUntilOutputContains(""); } + [Theory] + [InlineData("PublishAot", "True")] + [InlineData("PublishTrimmed", "True")] + [InlineData("StartupHookSupport", "False")] + public async Task ChangeFileInAotProject(string propertyName, string propertyValue) + { + var tfm = ToolsetInfo.CurrentTargetFramework; + + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") + .WithSource() + .WithProjectChanges(project => + { + project.Root.Descendants() + .First(e => e.Name.LocalName == "PropertyGroup") + .Add(XElement.Parse($"<{propertyName}>{propertyValue}")); + }); + + var programPath = Path.Combine(testAsset.Path, "Program.cs"); + + App.Start(testAsset, ["--non-interactive"]); + + await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + App.AssertOutputContains($"[WatchHotReloadApp ({tfm})] " + MessageDescriptor.ProjectDoesNotSupportHotReload.GetMessage($"'{propertyName}' property is '{propertyValue}'")); + + UpdateSourceFile(programPath, content => content.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"\");")); + + await App.WaitForOutputLineContaining(""); + App.AssertOutputContains($"[auto-restart] {programPath}(1,1): error ENC0097"); // Applying source changes while the application is running is not supported by the runtime. + } + [Fact] public async Task ChangeFileInFSharpProject() { @@ -642,9 +672,8 @@ open System.Threading [] let main argv = - while true do - printfn "Waiting" - Thread.Sleep(200) + printfn "Waiting" + Thread.Sleep(Timeout.Infinite) 0 """; @@ -652,19 +681,18 @@ open System.Threading File.WriteAllText(sourcePath, source); - App.Start(testAsset, []); + App.Start(testAsset, ["--non-interactive"]); await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); - UpdateSourceFile(sourcePath, content => content.Replace("Waiting", "")); await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); - await App.AssertOutputLineStartsWith(""); + await App.WaitUntilOutputContains(""); UpdateSourceFile(sourcePath, content => content.Replace("", "")); await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); - await App.AssertOutputLineStartsWith(""); + await App.WaitUntilOutputContains(""); } // Test is timing out on .NET Framework: https://github.com/dotnet/sdk/issues/41669 @@ -997,7 +1025,7 @@ public async Task Razor_Component_ScopedCssAndStaticAssets() UpdateSourceFile(scopedCssPath, newCss); await App.WaitForOutputLineContaining(MessageDescriptor.StaticAssetsChangesApplied); - await App.WaitUntilOutputContains(MessageDescriptor.NoCSharpChangesToApply); + await App.WaitUntilOutputContains(MessageDescriptor.NoManagedCodeChangesToApply); App.AssertOutputContains(MessageDescriptor.SendingStaticAssetUpdateRequest.GetMessage("wwwroot/RazorClassLibrary.bundle.scp.css")); App.Process.ClearOutput(); @@ -1006,7 +1034,7 @@ public async Task Razor_Component_ScopedCssAndStaticAssets() UpdateSourceFile(cssPath, content => content.Replace("background-color: white;", "background-color: red;")); await App.WaitForOutputLineContaining(MessageDescriptor.StaticAssetsChangesApplied); - await App.WaitUntilOutputContains(MessageDescriptor.NoCSharpChangesToApply); + await App.WaitUntilOutputContains(MessageDescriptor.NoManagedCodeChangesToApply); App.AssertOutputContains(MessageDescriptor.SendingStaticAssetUpdateRequest.GetMessage("wwwroot/app.css")); App.Process.ClearOutput(); @@ -1051,7 +1079,7 @@ public async Task MauiBlazor() await App.WaitForOutputLineContaining(MessageDescriptor.StaticAssetsChangesApplied); App.AssertOutputContains("Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager.UpdateContent"); - App.AssertOutputContains(MessageDescriptor.NoCSharpChangesToApply); + App.AssertOutputContains(MessageDescriptor.NoManagedCodeChangesToApply); App.Process.ClearOutput(); // update scoped css: @@ -1060,7 +1088,7 @@ public async Task MauiBlazor() await App.WaitForOutputLineContaining(MessageDescriptor.StaticAssetsChangesApplied); App.AssertOutputContains("Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager.UpdateContent"); - App.AssertOutputContains(MessageDescriptor.NoCSharpChangesToApply); + App.AssertOutputContains(MessageDescriptor.NoManagedCodeChangesToApply); } // Test is timing out on .NET Framework: https://github.com/dotnet/sdk/issues/41669 diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index 3ed141cd9765..9e4a1ef710ba 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -23,6 +23,7 @@ public async Task ReferenceOutputAssembly_False() var factory = new ProjectGraphFactory(globalOptions: []); var projectGraph = factory.TryLoadProjectGraph(options.ProjectPath, NullLogger.Instance, projectGraphRequired: false, CancellationToken.None); + Assert.NotNull(projectGraph); var processOutputReporter = new TestProcessOutputReporter(); diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 9495719fe794..4e7c83ffa867 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -85,7 +85,7 @@ private static async Task Launch(string projectPath, TestRuntime Assert.NotNull(result); - await result.WaitForProcessRunningAsync(cancellationToken); + await result.Clients.WaitForConnectionEstablishedAsync(cancellationToken); return result; }); @@ -607,7 +607,7 @@ public async Task IgnoredChange(bool isExisting, bool isIncluded, DirectoryKind var ignoringChangeInExcludedFile = w.Reporter.RegisterSemaphore(MessageDescriptor.IgnoringChangeInExcludedFile); var fileAdditionTriggeredReEvaluation = w.Reporter.RegisterSemaphore(MessageDescriptor.FileAdditionTriggeredReEvaluation); var reEvaluationCompleted = w.Reporter.RegisterSemaphore(MessageDescriptor.ReEvaluationCompleted); - var noHotReloadChangesToApply = w.Reporter.RegisterSemaphore(MessageDescriptor.NoCSharpChangesToApply); + var noHotReloadChangesToApply = w.Reporter.RegisterSemaphore(MessageDescriptor.NoManagedCodeChangesToApply); Log("Waiting for changes..."); await waitingForChanges.WaitAsync(w.ShutdownSource.Token);