diff --git a/Directory.Packages.props b/Directory.Packages.props index 1a83625bdc29..5ff2c6fbfb20 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,6 +37,7 @@ + diff --git a/NuGet.config b/NuGet.config index 85e13e71ea71..9470d3bc87c3 100644 --- a/NuGet.config +++ b/NuGet.config @@ -37,6 +37,7 @@ + diff --git a/eng/Version.Details.props b/eng/Version.Details.props index e1ef1efed51d..244c812c5688 100644 --- a/eng/Version.Details.props +++ b/eng/Version.Details.props @@ -21,17 +21,18 @@ This file should be imported by eng/Versions.props 18.1.0-preview-25568-105 18.1.0-preview-25568-105 7.1.0-preview.1.6905 - 5.3.0-1.25568.105 - 5.3.0-1.25568.105 - 5.3.0-1.25568.105 - 5.3.0-1.25568.105 - 5.3.0-1.25568.105 - 5.3.0-1.25568.105 - 5.3.0-1.25568.105 - 5.3.0-1.25568.105 + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev 10.0.0-preview.25568.105 - 5.3.0-1.25568.105 - 5.3.0-1.25568.105 + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev 10.0.0-beta.25568.105 10.0.0-beta.25568.105 10.0.0-beta.25568.105 @@ -41,8 +42,8 @@ This file should be imported by eng/Versions.props 10.0.0-beta.25568.105 10.0.0-beta.25568.105 15.0.200-servicing.25568.105 - 5.3.0-1.25568.105 - 5.3.0-1.25568.105 + 5.3.0-dev + 5.3.0-dev 10.0.0-preview.7.25377.103 10.0.0-preview.25568.105 18.1.0-preview-25568-105 @@ -159,18 +160,18 @@ This file should be imported by eng/Versions.props 18.1.0-preview-25523-02 18.1.0-preview-25523-02 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 - 5.3.0-2.25560.14 + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev + 5.3.0-dev 7.1.0-preview.1.42 7.1.0-preview.1.42 diff --git a/src/BuiltInTools/Watch/Build/EvaluationResult.cs b/src/BuiltInTools/Watch/Build/EvaluationResult.cs index bb04a3683a7e..d55b4644bfdd 100644 --- a/src/BuiltInTools/Watch/Build/EvaluationResult.cs +++ b/src/BuiltInTools/Watch/Build/EvaluationResult.cs @@ -2,12 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using Microsoft.Build.Execution; using Microsoft.Build.Graph; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; -internal sealed class EvaluationResult(IReadOnlyDictionary files, ProjectGraph projectGraph) +internal sealed class EvaluationResult(IReadOnlyDictionary files, ProjectGraph projectGraph, ImmutableArray restoredProjectInstances) { public readonly IReadOnlyDictionary Files = files; public readonly ProjectGraph ProjectGraph = projectGraph; @@ -26,6 +27,9 @@ private static IReadOnlySet CreateBuildFileSet(ProjectGraph projectGraph public IReadOnlySet BuildFiles => _lazyBuildFiles.Value; + public ImmutableArray RestoredProjectInstances + => restoredProjectInstances; + public void WatchFiles(FileWatcher fileWatcher) { fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true); @@ -86,14 +90,18 @@ 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(); + // 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) == "") @@ -116,7 +124,6 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera var projectPath = projectInstance.FullPath; var projectDirectory = Path.GetDirectoryName(projectPath)!; - // TODO: Compile and AdditionalItems should be provided by Roslyn var items = projectInstance.GetItems(ItemNames.Compile) .Concat(projectInstance.GetItems(ItemNames.AdditionalFiles)) .Concat(projectInstance.GetItems(ItemNames.Watch)); @@ -166,6 +173,6 @@ void AddFile(string include, string? staticWebAssetPath) buildReporter.ReportWatchedFiles(fileItems); - return new EvaluationResult(fileItems, projectGraph); + return new EvaluationResult(fileItems, projectGraph, restoredProjectInstances); } } diff --git a/src/BuiltInTools/Watch/Build/FilePathExclusions.cs b/src/BuiltInTools/Watch/Build/FilePathExclusions.cs index 305ef3c0ab9e..f7b54066dadb 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.GetIntermediateOutputDirectory()); void TryAddOutputDir(string? dir) diff --git a/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs b/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs index 40314297b76a..8469a0968f4e 100644 --- a/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs +++ b/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs @@ -48,8 +48,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 2929d4f1e71e..a1c1094310bd 100644 --- a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Diagnostics; +using Microsoft.Build.Execution; using Microsoft.Build.Graph; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -14,7 +15,7 @@ namespace Microsoft.DotNet.Watch { internal sealed class CompilationHandler : IDisposable { - public readonly IncrementalMSBuildWorkspace Workspace; + public readonly HotReloadMSBuildWorkspace Workspace; private readonly ILogger _logger; private readonly HotReloadService _hotReloadService; private readonly ProcessRunner _processRunner; @@ -38,12 +39,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(ILogger logger, ProcessRunner processRunner) { _logger = logger; _processRunner = processRunner; - Workspace = new IncrementalMSBuildWorkspace(logger); + Workspace = new HotReloadMSBuildWorkspace(logger, projectFile => _projectInstances.GetValueOrDefault(projectFile, [])); _hotReloadService = new HotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities())); } @@ -691,5 +699,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, string baseDirectory, 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 5466d27b32c2..17e661b9a0dd 100644 --- a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Diagnostics; +using Microsoft.Build.Execution; using Microsoft.Build.Graph; using Microsoft.CodeAnalysis; using Microsoft.DotNet.HotReload; @@ -166,7 +167,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) return; } - await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.ProjectPath, iterationCancellationToken); + await compilationHandler.UpdateProjectConeAsync(evaluationResult.ProjectGraph, rootProjectOptions.ProjectPath, rootProjectOptions.WorkingDirectory, 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. @@ -373,7 +374,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); } @@ -454,7 +455,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra // additional files/directories may have been added: evaluationResult.WatchFiles(fileWatcher); - await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.ProjectPath, iterationCancellationToken); + await compilationHandler.UpdateProjectConeAsync(evaluationResult.ProjectGraph, rootProjectOptions.ProjectPath, rootProjectOptions.WorkingDirectory, iterationCancellationToken); if (shutdownCancellationToken.IsCancellationRequested) { @@ -501,7 +502,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra { // 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; @@ -560,35 +561,38 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra } } - 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 06b79772ddf6..000000000000 --- a/src/BuiltInTools/Watch/HotReload/IncrementalMSBuildWorkspace.cs +++ /dev/null @@ -1,272 +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 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, 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) - .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(); - } - - 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) - { -#if DEBUG - _logger.LogDebug("Solution: {Path}", solution.FilePath); - - if (!_logger.IsEnabled(LogLevel.Debug)) - { - return; - } - - _logger.LogDebug("Solution after {Operation}: v{Version}", operationDisplayName, updateId); - - 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())); - } -#else - await Task.CompletedTask; -#endif - } -} diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index 9e28729eb807..e27bb7abaa50 100644 --- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json +++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json @@ -3,7 +3,7 @@ "dotnet-watch": { "commandName": "Project", "commandLineArgs": "--verbose -bl", - "workingDirectory": "C:\\bugs\\9756\\aspire-watch-start-issue\\Aspire.AppHost", + "workingDirectory": "E:\\Temp\\FS\\PFApp", "environmentVariables": { "DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)", "DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000", diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index 4081a1e2102c..a20bae5f2da0 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -25,10 +25,13 @@ public async Task ReferenceOutputAssembly_False() var loggerFactory = new LoggerFactory(reporter); var logger = loggerFactory.CreateLogger("Test"); var factory = new ProjectGraphFactory(globalOptions: []); + var projectGraph = factory.TryLoadProjectGraph(options.ProjectPath, logger, projectGraphRequired: false, CancellationToken.None); + Assert.NotNull(projectGraph); + var handler = new CompilationHandler(logger, processRunner); - await handler.Workspace.UpdateProjectConeAsync(hostProject, CancellationToken.None); + await handler.UpdateProjectConeAsync(projectGraph, hostProject, workingDirectory, CancellationToken.None); // all projects are present AssertEx.SequenceEqual(["Host", "Lib2", "Lib", "A", "B"], handler.Workspace.CurrentSolution.Projects.Select(p => p.Name));