-
Notifications
You must be signed in to change notification settings - Fork 4.2k
HotReloadMSBuildWorkspace #81577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
HotReloadMSBuildWorkspace #81577
Changes from all commits
e9db9f3
3c57f4d
ff31a93
47b105e
100147d
4668c8e
8485f82
e884d4f
4cf5350
cf19ac6
7480dc7
44d63ea
3b42f9f
a5cc206
cab0189
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
| // See the LICENSE file in the project root for more information. | ||
|
|
||
| namespace Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; | ||
|
|
||
| internal enum HotReloadFileChangeKind | ||
| { | ||
| Update, | ||
| Add, | ||
| Delete | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
| // See the LICENSE file in the project root for more information. | ||
|
|
||
| extern alias BuildHost; | ||
|
|
||
| using System; | ||
| using System.Collections.Immutable; | ||
| using System.IO; | ||
| using System.Text; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Internal; | ||
| using Microsoft.CodeAnalysis.MSBuild; | ||
|
|
||
| using MSB = Microsoft.Build; | ||
| using MSBuildHost = BuildHost::Microsoft.CodeAnalysis.MSBuild; | ||
|
|
||
| namespace Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; | ||
|
|
||
| internal sealed partial class HotReloadMSBuildWorkspace | ||
| { | ||
| private sealed class ProjectFileInfoProvider( | ||
| Func<string, (ImmutableArray<MSB.Execution.ProjectInstance> instances, MSB.Evaluation.Project? project)> getBuildProjects, | ||
| ProjectFileExtensionRegistry projectFileExtensionRegistry) | ||
| : IProjectFileInfoProvider | ||
| { | ||
| public Task<ImmutableArray<ProjectFileInfo>> LoadProjectFileInfosAsync(string projectPath, DiagnosticReportingOptions reportingOptions, CancellationToken cancellationToken) | ||
| { | ||
| var (instances, project) = getBuildProjects(projectPath); | ||
|
|
||
| if (instances.IsEmpty || | ||
| !projectFileExtensionRegistry.TryGetLanguageNameFromProjectPath(projectPath, DiagnosticReportingMode.Ignore, out var languageName)) | ||
| { | ||
| return Task.FromResult(ImmutableArray<ProjectFileInfo>.Empty); | ||
| } | ||
|
|
||
| return Task.FromResult(instances.SelectAsArray(instance => | ||
| { | ||
| var reader = new MSBuildHost.ProjectInstanceReader( | ||
| MSBuildHost.ProjectCommandLineProvider.Create(languageName), | ||
| instance, | ||
| project); | ||
|
|
||
| return reader.CreateProjectFileInfo().Convert(); | ||
| })); | ||
| } | ||
|
|
||
| public Task<ImmutableArray<string>> GetProjectOutputPathsAsync(string projectPath, CancellationToken cancellationToken) | ||
| => Task.FromResult( | ||
| getBuildProjects(projectPath).instances.SelectAsArray(static instance => instance.GetPropertyValue(MSBuildHost.PropertyNames.TargetPath))); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
| // See the LICENSE file in the project root for more information. | ||
|
|
||
| extern alias BuildHost; | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Collections.Immutable; | ||
| using System.Diagnostics; | ||
| using System.IO; | ||
| using System.Linq; | ||
| using System.Text; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.CodeAnalysis; | ||
| using Microsoft.CodeAnalysis.Host.Mef; | ||
| using Microsoft.CodeAnalysis.MSBuild; | ||
| using Microsoft.CodeAnalysis.Text; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| using MSB = Microsoft.Build; | ||
|
|
||
| namespace Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; | ||
|
|
||
| internal sealed partial class HotReloadMSBuildWorkspace : Workspace | ||
| { | ||
| private readonly ILogger _logger; | ||
| private readonly MSBuildProjectLoader _loader; | ||
| private readonly ProjectFileInfoProvider _projectGraphFileInfoProvider; | ||
|
|
||
| public HotReloadMSBuildWorkspace(ILogger logger, Func<string, (ImmutableArray<MSB.Execution.ProjectInstance> instances, MSB.Evaluation.Project? project)> getBuildProjects) | ||
tmat marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| : base(MSBuildMefHostServices.DefaultServices, WorkspaceKind.MSBuild) | ||
| { | ||
| RegisterWorkspaceFailedHandler(args => | ||
| { | ||
| // 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: {args.Diagnostic}"); | ||
| }); | ||
|
|
||
| _logger = logger; | ||
| _loader = new MSBuildProjectLoader(this); | ||
| _projectGraphFileInfoProvider = new ProjectFileInfoProvider(getBuildProjects, _loader.ProjectFileExtensionRegistry); | ||
| } | ||
|
|
||
| public async ValueTask<Solution> UpdateProjectConeAsync(string projectPath, CancellationToken cancellationToken) | ||
tmat marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| Contract.ThrowIfFalse(Path.IsPathFullyQualified(projectPath)); | ||
|
|
||
| var oldSolution = CurrentSolution; | ||
|
|
||
| var projectMap = ProjectMap.Create(); | ||
|
|
||
| var projectInfos = await _loader.LoadInfosAsync( | ||
| [projectPath], | ||
| _projectGraphFileInfoProvider, | ||
| projectMap, | ||
| progress: null, | ||
| cancellationToken).ConfigureAwait(false); | ||
|
|
||
| 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) | ||
| { | ||
| Contract.ThrowIfNull(newProjectInfo.FilePath); | ||
|
|
||
| var oldProjectId = projectIdMap[newProjectInfo.Id]; | ||
| if (oldProjectId == null) | ||
| { | ||
| newSolution = newSolution.AddProject(newProjectInfo); | ||
| continue; | ||
| } | ||
|
|
||
| newSolution = newSolution.WithProjectInfo(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)); | ||
| } | ||
|
|
||
| var result = SetCurrentSolution(newSolution); | ||
| UpdateReferencesAfterAdd(); | ||
|
|
||
| return result; | ||
|
|
||
| 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. | ||
tmat marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return new( | ||
| projectId: projectIdMap.TryGetValue(pr.ProjectId, out var oldProjectId) && oldProjectId != null ? oldProjectId : pr.ProjectId, | ||
| aliases: pr.Aliases, | ||
| embedInteropTypes: pr.EmbedInteropTypes); | ||
| } | ||
|
|
||
| ImmutableArray<DocumentInfo> MapDocuments(ProjectId mappedProjectId, IReadOnlyList<DocumentInfo> documents) | ||
| => documents.Select(docInfo => | ||
| { | ||
| // TODO: can there be multiple documents of the same path in the project? | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to document it somewhere, yes that can happen if a file is added twice. Or is added as both a source file and an additional file. None of these are "real" scenarios, but can happen in broken project scenarios. The only expectation is we don't crash. There might be a bug here since maybe if a source file is being added as an additional file we might try to reuse the same ID which would be bad in this case. Maybe file a bug for tracking, if we refactor this later this code might go away. |
||
|
|
||
| // 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<Solution> UpdateFileContentAsync(IEnumerable<(string path, HotReloadFileChangeKind change)> changedFiles, CancellationToken cancellationToken) | ||
| { | ||
| var updatedSolution = CurrentSolution; | ||
|
|
||
| var documentsToRemove = new List<DocumentId>(); | ||
|
|
||
| foreach (var (path, change) in changedFiles) | ||
| { | ||
| // when a file is added we reevaluate the project: | ||
| Contract.ThrowIfTrue(change == HotReloadFileChangeKind.Add); | ||
|
|
||
| var documentIds = updatedSolution.GetDocumentIdsWithFilePath(path); | ||
| if (change == HotReloadFileChangeKind.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.", path); | ||
| continue; | ||
| } | ||
|
|
||
| var project = updatedSolution.GetProject(documentId.ProjectId); | ||
| Debug.Assert(project?.FilePath != null); | ||
|
|
||
| var oldText = await textDocument.GetTextAsync(cancellationToken).ConfigureAwait(false); | ||
| Debug.Assert(oldText.Encoding != null); | ||
|
|
||
| var newText = await GetSourceTextAsync(path, oldText.Encoding, oldText.ChecksumAlgorithm, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| _logger.LogDebug("Updating document text of '{FilePath}'.", path); | ||
|
|
||
| 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 ExceptionUtilities.UnexpectedValue(textDocument), | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| updatedSolution = RemoveDocuments(updatedSolution, documentsToRemove); | ||
|
|
||
| return SetCurrentSolution(updatedSolution); | ||
| } | ||
|
|
||
| private static Solution RemoveDocuments(Solution solution, IEnumerable<DocumentId> 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<SourceText> 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 | FileShare.Delete)) | ||
| { | ||
| 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. | ||
tmat marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| await Task.Delay(20, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| 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).ConfigureAwait(false); | ||
| } | ||
| } | ||
|
|
||
| throw ExceptionUtilities.Unreachable(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
| // See the LICENSE file in the project root for more information. | ||
|
|
||
| extern alias BuildHost; | ||
| using Microsoft.CodeAnalysis.MSBuild; | ||
|
|
||
| namespace Microsoft.CodeAnalysis.ExternalAccess.HotReload.Internal; | ||
|
|
||
| internal static class ContractConversions | ||
tmat marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| public static ProjectFileInfo Convert(this BuildHost::Microsoft.CodeAnalysis.MSBuild.ProjectFileInfo info) | ||
| => new() | ||
| { | ||
| IsEmpty = info.IsEmpty, | ||
| Language = info.Language, | ||
| FilePath = info.FilePath, | ||
| OutputFilePath = info.OutputFilePath, | ||
| OutputRefFilePath = info.OutputRefFilePath, | ||
| IntermediateOutputFilePath = info.IntermediateOutputFilePath, | ||
| GeneratedFilesOutputDirectory = info.GeneratedFilesOutputDirectory, | ||
| DefaultNamespace = info.DefaultNamespace, | ||
| TargetFramework = info.TargetFramework, | ||
| TargetFrameworkIdentifier = info.TargetFrameworkIdentifier, | ||
| CommandLineArgs = info.CommandLineArgs, | ||
| Documents = info.Documents.SelectAsArray(Convert), | ||
| AdditionalDocuments = info.AdditionalDocuments.SelectAsArray(Convert), | ||
| AnalyzerConfigDocuments = info.AnalyzerConfigDocuments.SelectAsArray(Convert), | ||
| ProjectReferences = info.ProjectReferences.SelectAsArray(Convert), | ||
| ProjectCapabilities = info.ProjectCapabilities, | ||
| ContentFilePaths = info.ContentFilePaths, | ||
| ProjectAssetsFilePath = info.ProjectAssetsFilePath, | ||
| PackageReferences = info.PackageReferences.SelectAsArray(Convert), | ||
| TargetFrameworkVersion = info.TargetFrameworkVersion, | ||
| FileGlobs = info.FileGlobs.SelectAsArray(Convert), | ||
| }; | ||
|
|
||
| public static DocumentFileInfo Convert(this BuildHost::Microsoft.CodeAnalysis.MSBuild.DocumentFileInfo info) | ||
| => new(info.FilePath, info.LogicalPath, info.IsLinked, info.IsGenerated, info.Folders); | ||
|
|
||
| public static ProjectFileReference Convert(this BuildHost::Microsoft.CodeAnalysis.MSBuild.ProjectFileReference reference) | ||
| => new(reference.Path, reference.Aliases, reference.ReferenceOutputAssembly); | ||
|
|
||
| public static PackageReference Convert(this BuildHost::Microsoft.CodeAnalysis.MSBuild.PackageReference reference) | ||
| => new(reference.Name, reference.VersionRange); | ||
|
|
||
| public static FileGlobs Convert(this BuildHost::Microsoft.CodeAnalysis.MSBuild.FileGlobs globs) | ||
| => new(globs.Includes, globs.Excludes, globs.Removes); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a note to reviewers, this is moving from https://github.com/dotnet/sdk/blob/2b7468dad75a2c8dc419cd30da4443ac6d699aa6/src/BuiltInTools/Watch/HotReload/IncrementalMSBuildWorkspace.cs.