diff --git a/src/EditorFeatures/Test/EditAndContinue/EditAndContinueLanguageServiceTests.cs b/src/EditorFeatures/Test/EditAndContinue/EditAndContinueLanguageServiceTests.cs index 07909235d0237..389c8beb70085 100644 --- a/src/EditorFeatures/Test/EditAndContinue/EditAndContinueLanguageServiceTests.cs +++ b/src/EditorFeatures/Test/EditAndContinue/EditAndContinueLanguageServiceTests.cs @@ -210,8 +210,9 @@ await localWorkspace.ChangeSolutionAsync(localWorkspace.CurrentSolution ]) ], SyntaxError = syntaxError, - ProjectsToRebuild = [project.Id], - ProjectsToRestart = ImmutableDictionary>.Empty.Add(project.Id, []) + ProjectsToRebuild = [projectId], + ProjectsToRestart = ImmutableDictionary>.Empty.Add(projectId, []), + ProjectsToRedeploy = [projectId], }; }; @@ -276,6 +277,7 @@ await localWorkspace.ChangeSolutionAsync(localWorkspace.CurrentSolution SyntaxError = null, ProjectsToRebuild = [], ProjectsToRestart = ImmutableDictionary>.Empty, + ProjectsToRedeploy = [], }; }; diff --git a/src/Features/Core/Portable/EditAndContinue/DebuggingSession.cs b/src/Features/Core/Portable/EditAndContinue/DebuggingSession.cs index 7c80213bedb22..64f8410edf233 100644 --- a/src/Features/Core/Portable/EditAndContinue/DebuggingSession.cs +++ b/src/Features/Core/Portable/EditAndContinue/DebuggingSession.cs @@ -601,7 +601,8 @@ public async ValueTask EmitSolutionUpdateAsync( Diagnostics = solutionUpdate.Diagnostics, SyntaxError = solutionUpdate.SyntaxError, ProjectsToRestart = solutionUpdate.ProjectsToRestart, - ProjectsToRebuild = solutionUpdate.ProjectsToRebuild + ProjectsToRebuild = solutionUpdate.ProjectsToRebuild, + ProjectsToRedeploy = solutionUpdate.ProjectsToRedeploy, }; } @@ -787,7 +788,8 @@ public async ValueTask>> GetB var oldProject = LastCommittedSolution.GetProject(projectId); if (oldProject == null) { - // document is in a project that's been added to the solution + // Document is in a project that's been added to the solution + // No need to map the breakpoint from its original (base) location supplied by the debugger to a new one. continue; } @@ -807,7 +809,9 @@ public async ValueTask>> GetB var (oldDocument, _) = await LastCommittedSolution.GetDocumentAndStateAsync(newDocument, cancellationToken).ConfigureAwait(false); if (oldDocument == null) { - // Document is out-of-sync, can't reason about its content with respect to the binaries loaded in the debuggee. + // Document is either + // 1) added -- no need to map the breakpoint from original location to a new one + // 2) out-of-sync, in which case we can't reason about its content with respect to the binaries loaded in the debuggee. continue; } @@ -906,7 +910,7 @@ public async ValueTask> GetAdjustedActiveSta var oldProject = LastCommittedSolution.GetProject(newProject.Id); if (oldProject == null) { - // TODO: https://github.com/dotnet/roslyn/issues/1204 + // TODO: https://github.com/dotnet/roslyn/issues/79423 // Enumerate all documents of the new project. return []; } @@ -939,7 +943,7 @@ public async ValueTask> GetAdjustedActiveSta var (oldUnmappedDocument, _) = await LastCommittedSolution.GetDocumentAndStateAsync(newUnmappedDocument, cancellationToken).ConfigureAwait(false); if (oldUnmappedDocument == null) { - // document out-of-date + // document added or out-of-date continue; } diff --git a/src/Features/Core/Portable/EditAndContinue/EditSession.cs b/src/Features/Core/Portable/EditAndContinue/EditSession.cs index 1d79cd474bc7e..11c4f4b7895e9 100644 --- a/src/Features/Core/Portable/EditAndContinue/EditSession.cs +++ b/src/Features/Core/Portable/EditAndContinue/EditSession.cs @@ -343,6 +343,11 @@ internal static async ValueTask HasDifferencesAsync(Project oldProject, Pr return false; } + if (HasProjectLevelDifferences(oldProject, newProject, differences) && differences == null) + { + return true; + } + foreach (var documentId in newProject.State.DocumentStates.GetChangedStateIds(oldProject.State.DocumentStates, ignoreUnchangedContent: true)) { var document = newProject.GetRequiredDocument(documentId); @@ -361,7 +366,7 @@ internal static async ValueTask HasDifferencesAsync(Project oldProject, Pr return true; } - differences.Value.ChangedOrAddedDocuments.Add(document); + differences.ChangedOrAddedDocuments.Add(document); } foreach (var documentId in newProject.State.DocumentStates.GetAddedStateIds(oldProject.State.DocumentStates)) @@ -377,7 +382,7 @@ internal static async ValueTask HasDifferencesAsync(Project oldProject, Pr return true; } - differences.Value.ChangedOrAddedDocuments.Add(document); + differences.ChangedOrAddedDocuments.Add(document); } foreach (var documentId in newProject.State.DocumentStates.GetRemovedStateIds(oldProject.State.DocumentStates)) @@ -393,7 +398,7 @@ internal static async ValueTask HasDifferencesAsync(Project oldProject, Pr return true; } - differences.Value.DeletedDocuments.Add(document); + differences.DeletedDocuments.Add(document); } // The following will check for any changes in non-generated document content (editorconfig, additional docs). @@ -436,10 +441,64 @@ internal static async ValueTask HasDifferencesAsync(Project oldProject, Pr return false; } - internal static async Task GetProjectDifferencesAsync(TraceLog log, Project oldProject, Project newProject, ProjectDifferences documentDifferences, ArrayBuilder diagnostics, CancellationToken cancellationToken) + /// + /// Return true if projects might have differences in state other than document content that migth affect EnC. + /// The checks need to be fast. May return true even if the changes don't actually affect the behavior. + /// + internal static bool HasProjectLevelDifferences(Project oldProject, Project newProject, ProjectDifferences? differences) + { + Debug.Assert(oldProject.CompilationOptions != null); + Debug.Assert(newProject.CompilationOptions != null); + + if (oldProject.ParseOptions != newProject.ParseOptions || + HasDifferences(oldProject.CompilationOptions, newProject.CompilationOptions) || + oldProject.AssemblyName != newProject.AssemblyName) + { + if (differences != null) + { + differences.HasSettingChange = true; + } + else + { + return true; + } + } + + if (!oldProject.MetadataReferences.SequenceEqual(newProject.MetadataReferences) || + !oldProject.ProjectReferences.SequenceEqual(newProject.ProjectReferences)) + { + if (differences != null) + { + differences.HasReferenceChange = true; + } + else + { + return true; + } + } + + return false; + } + + /// + /// True if given compilation options differ in a way that might affect EnC. + /// + internal static bool HasDifferences(CompilationOptions oldOptions, CompilationOptions newOptions) + => !oldOptions + .WithSyntaxTreeOptionsProvider(newOptions.SyntaxTreeOptionsProvider) + .WithStrongNameProvider(newOptions.StrongNameProvider) + .WithXmlReferenceResolver(newOptions.XmlReferenceResolver) + .Equals(newOptions); + + internal static async Task GetProjectDifferencesAsync(TraceLog log, Project? oldProject, Project newProject, ProjectDifferences documentDifferences, ArrayBuilder diagnostics, CancellationToken cancellationToken) { documentDifferences.Clear(); + if (oldProject == null) + { + return; + } + if (!await HasDifferencesAsync(oldProject, newProject, documentDifferences, cancellationToken).ConfigureAwait(false)) { return; @@ -697,6 +756,16 @@ private static bool HasReferenceRudeEdits(ImmutableDictionary r.Name)); + return newCompilation.ReferencedAssemblyNames.Any(static (newReference, oldNames) => !oldNames.Contains(newReference.Name), oldNames); + } + internal static async ValueTask GetProjectChangesAsync( ActiveStatementsMap baseActiveStatements, Compilation oldCompilation, @@ -900,9 +969,11 @@ public async ValueTask EmitSolutionUpdateAsync( using var _1 = ArrayBuilder.GetInstance(out var deltas); using var _2 = ArrayBuilder<(Guid ModuleId, ImmutableArray<(ManagedModuleMethodId Method, NonRemappableRegion Region)>)>.GetInstance(out var nonRemappableRegions); using var _3 = ArrayBuilder.GetInstance(out var newProjectBaselines); - using var _4 = ArrayBuilder<(ProjectId id, Guid mvid)>.GetInstance(out var projectsToStale); - using var _5 = ArrayBuilder.GetInstance(out var projectsToUnstale); + using var _4 = ArrayBuilder.GetInstance(out var addedUnbuiltProjects); + using var _5 = ArrayBuilder.GetInstance(out var projectsToRedeploy); using var _6 = PooledDictionary>.GetInstance(out var diagnosticBuilders); + + // Project differences for currently analyzed project. Reused and cleared. using var projectDifferences = new ProjectDifferences(); // After all projects have been analyzed "true" value indicates changed document that is only included in stale projects. @@ -945,39 +1016,14 @@ void UpdateChangedDocumentsStaleness(bool isStale) } var oldProject = oldSolution.GetProject(newProject.Id); - if (oldProject == null) - { - Log.Write($"EnC state of {newProject.GetLogDisplay()} queried: project not loaded"); - - // TODO (https://github.com/dotnet/roslyn/issues/1204): - // - // When debugging session is started some projects might not have been loaded to the workspace yet (may be explicitly unloaded by the user). - // We capture the base solution. Edits in files that are in projects that haven't been loaded won't be applied - // and will result in source mismatch when the user steps into them. - // - // We can allow project to be added by including all its documents here. - // When we analyze these documents later on we'll check if they match the PDB. - // If so we can add them to the committed solution and detect further changes. - // It might be more efficient though to track added projects separately. - - continue; - } - - Debug.Assert(oldProject.SupportsEditAndContinue()); - - if (!oldProject.ProjectSettingsSupportEditAndContinue(Log)) - { - // reason alrady reported - continue; - } - - projectDiagnostics = ArrayBuilder.GetInstance(); + Debug.Assert(oldProject == null || oldProject.SupportsEditAndContinue()); await GetProjectDifferencesAsync(Log, oldProject, newProject, projectDifferences, projectDiagnostics, cancellationToken).ConfigureAwait(false); + projectDifferences.Log(Log, newProject); - if (projectDifferences.HasDocumentChanges) + if (projectDifferences.IsEmpty) { - Log.Write($"Found {projectDifferences.ChangedOrAddedDocuments.Count} potentially changed, {projectDifferences.DeletedDocuments.Count} deleted document(s) in project {newProject.GetLogDisplay()}"); + continue; } var (mvid, mvidReadError) = await DebuggingSession.GetProjectModuleIdAsync(newProject, cancellationToken).ConfigureAwait(false); @@ -989,8 +1035,9 @@ void UpdateChangedDocumentsStaleness(bool isStale) if (mvid == staleModuleId || mvidReadError != null) { Log.Write($"EnC state of {newProject.GetLogDisplay()} queried: project is stale"); - UpdateChangedDocumentsStaleness(isStale: true); + // Track changed documents that are only included in stale or unbuilt projects: + UpdateChangedDocumentsStaleness(isStale: true); continue; } @@ -1003,17 +1050,32 @@ void UpdateChangedDocumentsStaleness(bool isStale) // The MVID is required for emit so we consider the error permanent and report it here. // Bail before analyzing documents as the analysis needs to read the PDB which will likely fail if we can't even read the MVID. projectDiagnostics.Add(mvidReadError); - projectSummaryToReport = ProjectAnalysisSummary.ValidChanges; continue; } if (mvid == Guid.Empty) { - Log.Write($"Changes not applied to {newProject.GetLogDisplay()}: project not built"); + // If the project has been added to the solution, ask the project system to build it. + if (oldProject == null) + { + Log.Write($"Project build requested for {newProject.GetLogDisplay()}"); + addedUnbuiltProjects.Add(newProject.Id); + } + else + { + Log.Write($"Changes not applied to {newProject.GetLogDisplay()}: project not built"); + } + + // Track changed documents that are only included in stale or unbuilt projects: UpdateChangedDocumentsStaleness(isStale: true); continue; } + if (oldProject == null) + { + continue; + } + // Ensure that all changed documents are in-sync. Once a document is in-sync it can't get out-of-sync. // Therefore, results of further computations based on base snapshots of changed documents can't be invalidated by // incoming events updating the content of out-of-sync documents. @@ -1079,8 +1141,7 @@ void UpdateChangedDocumentsStaleness(bool isStale) // Unsupported changes in referenced assemblies will be reported below. if (projectSummary is ProjectAnalysisSummary.NoChanges or ProjectAnalysisSummary.ValidInsignificantChanges && - oldProject.MetadataReferences.SequenceEqual(newProject.MetadataReferences) && - oldProject.ProjectReferences.SequenceEqual(newProject.ProjectReferences)) + !projectDifferences.HasReferenceChange) { continue; } @@ -1140,6 +1201,14 @@ void UpdateChangedDocumentsStaleness(bool isStale) continue; } + // If the project references new dependencies, the host needs to invoke ReferenceCopyLocalPathsOutputGroup target on this project + // to deploy these dependencies to the projects output directory. The deployment shouldn't overwrite existing files. + // It should only happen if the project has no rude edits (especially not rude edits related to references) -- we bailed above if so. + if (HasAddedReference(oldCompilation, newCompilation)) + { + projectsToRedeploy.Add(newProject.Id); + } + if (projectSummary is ProjectAnalysisSummary.NoChanges or ProjectAnalysisSummary.ValidInsignificantChanges) { continue; @@ -1286,9 +1355,9 @@ async ValueTask LogDocumentChangesAsync(int? generation, CancellationToken cance } finally { - if (projectSummaryToReport.HasValue) + if (projectSummaryToReport.HasValue || !projectDiagnostics.IsEmpty) { - Telemetry.LogProjectAnalysisSummary(projectSummaryToReport.Value, newProject.State.ProjectInfo.Attributes.TelemetryId, projectDiagnostics); + Telemetry.LogProjectAnalysisSummary(projectSummaryToReport, newProject.State.ProjectInfo.Attributes.TelemetryId, projectDiagnostics); } if (!projectDiagnostics.IsEmpty) @@ -1338,6 +1407,7 @@ async ValueTask LogDocumentChangesAsync(int? generation, CancellationToken cance solution, updates, diagnostics, + addedUnbuiltProjects, runningProjects, out var projectsToRestart, out var projectsToRebuild); @@ -1352,7 +1422,8 @@ async ValueTask LogDocumentChangesAsync(int? generation, CancellationToken cance diagnostics, syntaxError: null, projectsToRestart, - projectsToRebuild); + projectsToRebuild, + projectsToRedeploy.ToImmutable()); } catch (Exception e) when (LogException(e) && FatalError.ReportAndPropagateUnlessCanceled(e, cancellationToken)) { diff --git a/src/Features/Core/Portable/EditAndContinue/EditSessionTelemetry.cs b/src/Features/Core/Portable/EditAndContinue/EditSessionTelemetry.cs index a64fa9a623596..44b2d71439753 100644 --- a/src/Features/Core/Portable/EditAndContinue/EditSessionTelemetry.cs +++ b/src/Features/Core/Portable/EditAndContinue/EditSessionTelemetry.cs @@ -87,7 +87,7 @@ public void LogAnalysisTime(TimeSpan span) public void LogSyntaxError() => _hadSyntaxErrors = true; - public void LogProjectAnalysisSummary(ProjectAnalysisSummary summary, Guid projectTelemetryId, IEnumerable diagnostics) + public void LogProjectAnalysisSummary(ProjectAnalysisSummary? summary, Guid projectTelemetryId, IEnumerable diagnostics) { lock (_guard) { @@ -110,6 +110,10 @@ public void LogProjectAnalysisSummary(ProjectAnalysisSummary summary, Guid proje switch (summary) { + case null: + // report diagnostics only + break; + case ProjectAnalysisSummary.NoChanges: break; diff --git a/src/Features/Core/Portable/EditAndContinue/EmitSolutionUpdateResults.cs b/src/Features/Core/Portable/EditAndContinue/EmitSolutionUpdateResults.cs index 60c4a97edd8d0..8cbcc14f815f2 100644 --- a/src/Features/Core/Portable/EditAndContinue/EmitSolutionUpdateResults.cs +++ b/src/Features/Core/Portable/EditAndContinue/EmitSolutionUpdateResults.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; @@ -11,6 +12,7 @@ using Microsoft.CodeAnalysis.Contracts.EditAndContinue; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.PooledObjects; +using Microsoft.CodeAnalysis.Serialization; using Microsoft.CodeAnalysis.Shared.Extensions; using Roslyn.Utilities; @@ -36,6 +38,9 @@ internal readonly struct Data [DataMember] public required ImmutableArray ProjectsToRebuild { get; init; } + [DataMember] + public required ImmutableArray ProjectsToRedeploy { get; init; } + internal ImmutableArray GetAllDiagnostics() { using var _ = ArrayBuilder.GetInstance(out var builder); @@ -90,7 +95,8 @@ public static Data CreateFromInternalError(Solution solution, string errorMessag Diagnostics = [DiagnosticData.Create(diagnostic, firstProject)], SyntaxError = null, ProjectsToRebuild = [.. runningProjects.Keys], - ProjectsToRestart = runningProjects.Keys.ToImmutableDictionary(keySelector: static p => p, elementSelector: static p => ImmutableArray.Create(p)) + ProjectsToRedeploy = [], + ProjectsToRestart = runningProjects.Keys.ToImmutableDictionary(keySelector: static p => p, elementSelector: static p => ImmutableArray.Create(p)), }; } } @@ -103,6 +109,7 @@ public static Data CreateFromInternalError(Solution solution, string errorMessag SyntaxError = null, ProjectsToRestart = ImmutableDictionary>.Empty, ProjectsToRebuild = [], + ProjectsToRedeploy = [], }; /// @@ -136,6 +143,12 @@ public static Data CreateFromInternalError(Solution solution, string errorMessag /// public required ImmutableArray ProjectsToRebuild { get; init; } + /// + /// Projects whose dependencies need to be deployed to their output directory, if not already present. + /// Unordered set. + /// + public required ImmutableArray ProjectsToRedeploy { get; init; } + public Data Dehydrate() => Solution == null ? new() @@ -145,6 +158,7 @@ public Data Dehydrate() SyntaxError = null, ProjectsToRestart = ImmutableDictionary>.Empty, ProjectsToRebuild = [], + ProjectsToRedeploy = [], } : new() { @@ -153,6 +167,7 @@ public Data Dehydrate() SyntaxError = GetSyntaxErrorData(), ProjectsToRestart = ProjectsToRestart, ProjectsToRebuild = ProjectsToRebuild, + ProjectsToRedeploy = ProjectsToRedeploy, }; private DiagnosticData? GetSyntaxErrorData() @@ -170,6 +185,7 @@ public Data Dehydrate() /// /// Returns projects that need to be rebuilt and/or restarted due to blocking rude edits in order to apply changes. /// + /// Projects that were added to the solution and not built yet. /// Identifies projects that have been launched. /// /// Running projects that have to be restarted and a list of projects with rude edits that caused the restart. @@ -183,6 +199,7 @@ internal static void GetProjectsToRebuildAndRestart( Solution solution, ImmutableArray moduleUpdates, ImmutableArray diagnostics, + IReadOnlyCollection addedUnbuiltProjects, ImmutableDictionary runningProjects, out ImmutableDictionary> projectsToRestart, out ImmutableArray projectsToRebuild) @@ -215,11 +232,10 @@ internal static void GetProjectsToRebuildAndRestart( using var _1 = ArrayBuilder.GetInstance(out var traversalStack); // Maps project to restart to all projects with rude edits that caused the restart: - var projectsToRestartBuilder = PooledDictionary>.GetInstance(); + using var _2 = PooledHashSet.GetInstance(out var projectsToRestartBuilder); + var projectsToRebuildBuilder = PooledDictionary>.GetInstance(); - using var _3 = PooledHashSet.GetInstance(out var projectsToRebuildBuilder); - using var _4 = ArrayBuilder<(ProjectId projectWithRudeEdits, ImmutableArray impactedRunningProjects)>.GetInstance(out var impactedRunningProjectMap); - using var _5 = ArrayBuilder.GetInstance(out var impactedRunningProjects); + using var _3 = ArrayBuilder<(ProjectId projectWithRudeEdits, ImmutableArray impactedRunningProjects)>.GetInstance(out var impactedRunningProjectMap); foreach (var (projectId, projectDiagnostics) in diagnostics) { @@ -229,21 +245,35 @@ internal static void GetProjectsToRebuildAndRestart( continue; } - AddImpactedRunningProjects(impactedRunningProjects, projectId, hasBlocking); - - foreach (var impactedRunningProject in impactedRunningProjects) + var hasImpactedRunningProjects = false; + foreach (var ancestor in GetAncestorsAndSelf(projectId)) { - projectsToRestartBuilder.MultiAdd(impactedRunningProject, projectId); + if (runningProjects.TryGetValue(ancestor, out var runningProject) && + (hasBlocking || runningProject.RestartWhenChangesHaveNoEffect)) + { + projectsToRebuildBuilder.MultiAdd(ancestor, projectId); + projectsToRestartBuilder.Add(ancestor); + + hasImpactedRunningProjects = true; + } } - if (hasBlocking && impactedRunningProjects is []) + if (hasBlocking && !hasImpactedRunningProjects) { // Projects with rude edits that do not impact running projects has to be rebuilt, // so that the change takes effect if it is loaded in future. - projectsToRebuildBuilder.Add(projectId); + projectsToRebuildBuilder.MultiAdd(projectId, projectId); } + } - impactedRunningProjects.Clear(); + // Rebuild unbuilt projects that have been added and impact a running project + // (a project reference was added). + foreach (var projectId in addedUnbuiltProjects) + { + if (GetAncestorsAndSelf(projectId).Where(runningProjects.ContainsKey).Any()) + { + projectsToRebuildBuilder.MultiAdd(projectId, projectId); + } } // At this point the restart set contains all running projects transitively affected by rude edits. @@ -257,69 +287,79 @@ internal static void GetProjectsToRebuildAndRestart( { foreach (var update in moduleUpdates) { - AddImpactedRunningProjects(impactedRunningProjects, update.ProjectId, isBlocking: true); - - foreach (var impactedRunningProject in impactedRunningProjects) + foreach (var ancestor in GetAncestorsAndSelf(update.ProjectId)) { - projectsToRestartBuilder.TryAdd(impactedRunningProject, []); + if (runningProjects.ContainsKey(ancestor)) + { + projectsToRebuildBuilder.TryAdd(ancestor, []); + projectsToRestartBuilder.Add(ancestor); + } } - - impactedRunningProjects.Clear(); } } } - else if (!moduleUpdates.IsEmpty && projectsToRestartBuilder.Count > 0) + else if (!moduleUpdates.IsEmpty && projectsToRebuildBuilder.Count > 0) { // The set of updated projects is usually much smaller than the number of all projects in the solution. - // We iterate over this set updating the reset set until no new project is added to the reset set. + // We iterate over this set updating the restart set until no new project is added to the restart set. // Once a project is determined to affect a running process, all running processes that - // reference this project are added to the reset set. The project is then removed from updated - // project set as it can't contribute any more running projects to the reset set. - // If an updated project does not affect reset set in a given iteration, it stays in the set - // because it may affect reset set later on, after another running project is added to it. + // reference this project are added to the restart set. The project is then removed from updated + // project set as it can't contribute any more running projects to the restart set. + // If an updated project does not affect restart set in a given iteration, it stays in the set + // because it may affect restart set later on, after another running project is added to it. using var _6 = PooledHashSet.GetInstance(out var updatedProjects); using var _7 = ArrayBuilder.GetInstance(out var updatedProjectsToRemove); - using var _8 = PooledHashSet.GetInstance(out var projectsThatCausedRestart); + using var _8 = PooledHashSet.GetInstance(out var projectsThatCausedRebuild); updatedProjects.AddRange(moduleUpdates.Select(static u => u.ProjectId)); while (true) { - Debug.Assert(updatedProjectsToRemove.IsEmpty); + updatedProjectsToRemove.Clear(); foreach (var updatedProjectId in updatedProjects) { - AddImpactedRunningProjects(impactedRunningProjects, updatedProjectId, isBlocking: true); + projectsThatCausedRebuild.Clear(); - Debug.Assert(projectsThatCausedRestart.Count == 0); + // A project being updated that is a transitive dependency of a running project and + // also transitive dependency of a project that needs to be rebuilt + // causes the running project to be restarted. - // collect all projects that caused restart of any of the impacted running projects: - foreach (var impactedRunningProject in impactedRunningProjects) + foreach (var ancestor in GetAncestorsAndSelf(updatedProjectId)) { - if (projectsToRestartBuilder.TryGetValue(impactedRunningProject, out var causes)) + if (projectsToRebuildBuilder.TryGetValue(ancestor, out var causes)) { - projectsThatCausedRestart.AddRange(causes); + projectsThatCausedRebuild.AddRange(causes); } } - if (projectsThatCausedRestart.Any()) + if (!projectsThatCausedRebuild.Any()) { - // The projects that caused the impacted running project to be restarted - // indirectly cause the running project that depends on the updated project to be restarted. - foreach (var impactedRunningProject in impactedRunningProjects) + continue; + } + + var hasImpactOnRestartSet = false; + foreach (var ancestor in GetAncestorsAndSelf(updatedProjectId)) + { + if (!runningProjects.ContainsKey(ancestor)) { - if (!projectsToRestartBuilder.ContainsKey(impactedRunningProject)) - { - projectsToRestartBuilder.MultiAddRange(impactedRunningProject, projectsThatCausedRestart); - } + continue; } - updatedProjectsToRemove.Add(updatedProjectId); + if (!projectsToRebuildBuilder.ContainsKey(ancestor)) + { + projectsToRebuildBuilder.MultiAddRange(ancestor, projectsThatCausedRebuild); + projectsToRestartBuilder.Add(ancestor); + + hasImpactOnRestartSet = true; + } } - impactedRunningProjects.Clear(); - projectsThatCausedRestart.Clear(); + if (hasImpactOnRestartSet) + { + updatedProjectsToRemove.Add(updatedProjectId); + } } if (updatedProjectsToRemove is []) @@ -329,35 +369,31 @@ internal static void GetProjectsToRebuildAndRestart( } updatedProjects.RemoveAll(updatedProjectsToRemove); - updatedProjectsToRemove.Clear(); } } - foreach (var (_, causes) in projectsToRestartBuilder) + foreach (var (_, causes) in projectsToRebuildBuilder) { causes.SortAndRemoveDuplicates(); } - projectsToRebuildBuilder.AddRange(projectsToRestartBuilder.Keys); - projectsToRestart = projectsToRestartBuilder.ToImmutableMultiDictionaryAndFree(); - projectsToRebuild = [.. projectsToRebuildBuilder]; + projectsToRebuild = [.. projectsToRebuildBuilder.Keys]; + + projectsToRestart = projectsToRebuildBuilder.ToImmutableMultiDictionaryAndFree( + where: static (id, projectsToRestartBuilder) => projectsToRestartBuilder.Contains(id), + projectsToRestartBuilder); + return; - void AddImpactedRunningProjects(ArrayBuilder impactedProjects, ProjectId initialProject, bool isBlocking) + IEnumerable GetAncestorsAndSelf(ProjectId initialProject) { - Debug.Assert(impactedProjects.IsEmpty); - - Debug.Assert(traversalStack.Count == 0); + traversalStack.Clear(); traversalStack.Push(initialProject); while (traversalStack.Count > 0) { var projectId = traversalStack.Pop(); - if (runningProjects.TryGetValue(projectId, out var runningProject) && - (isBlocking || runningProject.RestartWhenChangesHaveNoEffect)) - { - impactedProjects.Add(projectId); - } + yield return projectId; foreach (var referencingProjectId in graph.GetProjectsThatDirectlyDependOnThisProject(projectId)) { diff --git a/src/Features/Core/Portable/EditAndContinue/ProjectDifferences.cs b/src/Features/Core/Portable/EditAndContinue/ProjectDifferences.cs index aaad8f296649e..a27aacfb9e985 100644 --- a/src/Features/Core/Portable/EditAndContinue/ProjectDifferences.cs +++ b/src/Features/Core/Portable/EditAndContinue/ProjectDifferences.cs @@ -10,11 +10,21 @@ namespace Microsoft.CodeAnalysis.EditAndContinue; /// /// Differences between documents of old and new projects. /// -internal readonly struct ProjectDifferences() : IDisposable +internal sealed class ProjectDifferences() : IDisposable { public readonly ArrayBuilder ChangedOrAddedDocuments = ArrayBuilder.GetInstance(); public readonly ArrayBuilder DeletedDocuments = ArrayBuilder.GetInstance(); + /// + /// Projects differ in compilation options, parse options, or other project attributes. + /// + public bool HasSettingChange { get; set; } + + /// + /// Projects differ in project or metadata references. + /// + public bool HasReferenceChange { get; set; } + public void Dispose() { ChangedOrAddedDocuments.Free(); @@ -25,7 +35,7 @@ public bool HasDocumentChanges => !ChangedOrAddedDocuments.IsEmpty || !DeletedDocuments.IsEmpty; public bool Any() - => HasDocumentChanges; + => HasDocumentChanges || HasSettingChange || HasReferenceChange; public bool IsEmpty => !Any(); @@ -35,4 +45,22 @@ public void Clear() ChangedOrAddedDocuments.Clear(); DeletedDocuments.Clear(); } + + public void Log(TraceLog log, Project newProject) + { + if (HasDocumentChanges) + { + log.Write($"Found {ChangedOrAddedDocuments.Count} potentially changed, {DeletedDocuments.Count} deleted document(s) in project {newProject.GetLogDisplay()}"); + } + + if (HasReferenceChange) + { + log.Write($"References of project {newProject.GetLogDisplay()} changed"); + } + + if (HasSettingChange) + { + log.Write($"Settings of project {newProject.GetLogDisplay()} changed"); + } + } } diff --git a/src/Features/Core/Portable/EditAndContinue/SolutionUpdate.cs b/src/Features/Core/Portable/EditAndContinue/SolutionUpdate.cs index 5f75bc87c3d6d..43ba5361b8f81 100644 --- a/src/Features/Core/Portable/EditAndContinue/SolutionUpdate.cs +++ b/src/Features/Core/Portable/EditAndContinue/SolutionUpdate.cs @@ -17,7 +17,8 @@ internal readonly struct SolutionUpdate( ImmutableArray diagnostics, Diagnostic? syntaxError, ImmutableDictionary> projectsToRestart, - ImmutableArray projectsToRebuild) + ImmutableArray projectsToRebuild, + ImmutableArray projectsToRedeploy) { public readonly ModuleUpdates ModuleUpdates = moduleUpdates; public readonly ImmutableDictionary StaleProjects = staleProjects; @@ -29,6 +30,7 @@ internal readonly struct SolutionUpdate( public readonly Diagnostic? SyntaxError = syntaxError; public readonly ImmutableDictionary> ProjectsToRestart = projectsToRestart; public readonly ImmutableArray ProjectsToRebuild = projectsToRebuild; + public readonly ImmutableArray ProjectsToRedeploy = projectsToRedeploy; public static SolutionUpdate Empty( ImmutableArray diagnostics, @@ -43,7 +45,8 @@ public static SolutionUpdate Empty( diagnostics, syntaxError, projectsToRestart: ImmutableDictionary>.Empty, - projectsToRebuild: []); + projectsToRebuild: [], + projectsToRedeploy: []); internal void Log(TraceLog log, UpdateId updateId) { diff --git a/src/Features/Core/Portable/EditAndContinue/Utilities/Extensions.cs b/src/Features/Core/Portable/EditAndContinue/Utilities/Extensions.cs index 2b8fb6b262735..e8cc42f4d211d 100644 --- a/src/Features/Core/Portable/EditAndContinue/Utilities/Extensions.cs +++ b/src/Features/Core/Portable/EditAndContinue/Utilities/Extensions.cs @@ -7,6 +7,7 @@ using System.Linq; using Microsoft.CodeAnalysis.Contracts.EditAndContinue; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Text; using Roslyn.Utilities; @@ -72,26 +73,6 @@ void LogReason(string message) return true; } - /// - /// True if project settings are compatible with Edit and Continue. - /// - public static bool ProjectSettingsSupportEditAndContinue(this Project project, TraceLog? log = null) - { - Contract.ThrowIfFalse(project.SupportsEditAndContinue()); - Contract.ThrowIfNull(project.CompilationOptions); - - if (project.CompilationOptions.OptimizationLevel != OptimizationLevel.Debug) - { - LogReason(nameof(ProjectSettingKind.OptimizationLevel), project.CompilationOptions.OptimizationLevel.ToString()); - return false; - } - - void LogReason(string settingName, string value) - => log?.Write($"Project '{project.GetLogDisplay()}' setting '{settingName}' value '{value}' is not compatible with EnC"); - - return true; - } - public static string GetLogDisplay(this Project project) => project.FilePath != null ? $"'{project.FilePath}'" + (project.State.NameAndFlavor.flavor is { } flavor ? $" ('{flavor}')" : "") diff --git a/src/Features/Core/Portable/ExternalAccess/Watch/Api/WatchHotReloadService.cs b/src/Features/Core/Portable/ExternalAccess/Watch/Api/WatchHotReloadService.cs index bcdf7bdbdf077..283a5ff8329a8 100644 --- a/src/Features/Core/Portable/ExternalAccess/Watch/Api/WatchHotReloadService.cs +++ b/src/Features/Core/Portable/ExternalAccess/Watch/Api/WatchHotReloadService.cs @@ -114,17 +114,22 @@ public readonly struct Updates2 /// Updates to be applied to modules. Empty if there are blocking rude edits. /// Only updates to projects that are not included in are listed. /// - public ImmutableArray ProjectUpdates { get; init; } + public required ImmutableArray ProjectUpdates { get; init; } /// /// Running projects that need to be restarted due to rude edits in order to apply changes. /// - public ImmutableDictionary> ProjectsToRestart { get; init; } + public required ImmutableDictionary> ProjectsToRestart { get; init; } /// /// Projects with changes that need to be rebuilt in order to apply changes. /// - public ImmutableArray ProjectsToRebuild { get; init; } + public required ImmutableArray ProjectsToRebuild { get; init; } + + /// + /// Projects whose dependencies need to be deployed to their output directory, if not already present. + /// + public required ImmutableArray ProjectsToRedeploy { get; init; } } private static readonly ActiveStatementSpanProvider s_solutionActiveStatementSpanProvider = @@ -241,7 +246,8 @@ public async Task GetUpdatesAsync(Solution solution, ImmutableDictiona update.UpdatedTypes, update.RequiredCapabilities)), ProjectsToRestart = results.ProjectsToRestart, - ProjectsToRebuild = results.ProjectsToRebuild + ProjectsToRebuild = results.ProjectsToRebuild, + ProjectsToRedeploy = results.ProjectsToRedeploy, }; } diff --git a/src/Features/Test/EditAndContinue/EditAndContinueWorkspaceServiceTests.cs b/src/Features/Test/EditAndContinue/EditAndContinueWorkspaceServiceTests.cs index 2bfc794bdbd50..2224de5681c2d 100644 --- a/src/Features/Test/EditAndContinue/EditAndContinueWorkspaceServiceTests.cs +++ b/src/Features/Test/EditAndContinue/EditAndContinueWorkspaceServiceTests.cs @@ -231,7 +231,7 @@ public async Task DifferentDocumentWithSameContent() } [Theory, CombinatorialData] - public async Task ProjectThatDoesNotSupportEnC(bool breakMode) + public async Task ProjectThatDoesNotSupportEnC_Language(bool breakMode) { using var _ = CreateWorkspace(out var solution, out var service, [typeof(NoCompilationLanguageService)]); var project = solution.AddProject("dummy_proj", "dummy_proj", NoCompilationConstants.LanguageName); @@ -263,6 +263,52 @@ public async Task ProjectThatDoesNotSupportEnC(bool breakMode) Assert.Empty(diagnostics); } + [Fact] + public async Task ProjectThatDoesNotSupportEnC_Settings_OptimizationLevel() + { + using var _ = CreateWorkspace(out var solution, out var service); + + var source1 = """ + class C + { + } + """; + + var source2 = """ + class C + { + public void F() {} + } + """; + + var sourceFile = Temp.CreateFile().WriteAllText(source1, Encoding.UTF8); + + solution = solution. + AddTestProject("test", out var projectId). + WithCompilationOptions(options => options.WithOptimizationLevel(OptimizationLevel.Release)). + AddTestDocument(source1, sourceFile.Path, out var documentId).Project.Solution; + + var project = solution.GetRequiredProject(projectId); + EmitAndLoadLibraryToDebuggee(project); + + // Debugger reports error when trying to emit change for optimized module. We do not report another rude edit. + _debuggerService.IsEditAndContinueAvailable = _ => new ManagedHotReloadAvailability(ManagedHotReloadAvailabilityStatus.Optimized, localizedMessage: "*optimized*"); + + var debuggingSession = await StartDebuggingSessionAsync(service, solution); + + // change the source: + solution = solution.WithDocumentText(documentId, CreateText(source2)); + + var results = await EmitSolutionUpdateAsync(debuggingSession, solution, allowPartialUpdate: true); + Assert.Equal(ModuleUpdateStatus.Ready, results.ModuleUpdates.Status); + Assert.Empty(results.ModuleUpdates.Updates); + + AssertEx.Equal( + [ + $"test: {sourceFile.Path}: (2,0)-(2,22): Error ENC2012: {string.Format(FeaturesResources.EditAndContinueDisallowedByProject, ["test", "*optimized*"])}" + ], InspectDiagnostics(results.Diagnostics)); + } + [Fact] public async Task ProjectWithoutEffectiveGeneratedFilesOutputDirectory() { @@ -546,6 +592,52 @@ End Class EndDebuggingSession(debuggingSession); } + [Fact] + public async Task ProjectWithoutChanges() + { + using var _ = CreateWorkspace(out var solution, out var service); + + var source = """ + class A + { + } + """; + + var sourceFile = Temp.CreateFile().WriteAllText(source, Encoding.UTF8); + + solution = solution. + AddTestProject("A", out var projectId). + AddTestDocument(source, sourceFile.Path).Project.Solution; + + var project = solution.GetRequiredProject(projectId); + EmitAndLoadLibraryToDebuggee(project); + + var debuggingSession = await StartDebuggingSessionAsync(service, solution); + + var newRefOutPath = Path.Combine(TempRoot.Root, "newRef"); + solution = solution.WithProjectOutputRefFilePath(projectId, newRefOutPath); + + // No changes pertinent to EnC were made to the project, we should not read its outputs: + var mockOutputs = (MockCompilationOutputs)_mockCompilationOutputs[projectId]; + + mockOutputs.OpenAssemblyStreamImpl = () => + { + Assert.Fail("Should not open assembly"); + return null; + }; + + mockOutputs.OpenPdbStreamImpl = () => + { + Assert.Fail("Should not open PDB"); + return null; + }; + + var results = await EmitSolutionUpdateAsync(debuggingSession, solution, allowPartialUpdate: true); + Assert.Equal(ModuleUpdateStatus.None, results.ModuleUpdates.Status); + Assert.Empty(results.ModuleUpdates.Updates); + Assert.Empty(results.Diagnostics); + } + private static MetadataReference EmitLibraryReference(Version version, bool useStrongName = false) { var libSource = $$""" @@ -2385,27 +2477,162 @@ public async Task HasChanges_SourceGeneratorFailure() Assert.Contains("System.InvalidOperationException: Source generator failed", generatorDiagnostics.Single().GetMessage()); } - [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/1204")] - [WorkItem("https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1371694")] + [Fact] + public async Task HasChanges_Settings() + { + using var _ = CreateWorkspace(out var solution, out var service); + + var sourcePath = Path.Combine(TempRoot.Root, "A.cs"); + + solution = solution. + AddTestProject("A", out var projectId). + AddTestDocument("class C;", sourcePath).Project.Solution; + + var debuggingSession = await StartDebuggingSessionAsync(service, solution); + EnterBreakState(debuggingSession); + + var oldSolution = solution; + solution = solution + .GetRequiredProject(projectId) + .WithCompilationOptions(options => options.WithOverflowChecks(!options.CheckOverflow)).Solution; + + Assert.True(await EditSession.HasChangesAsync(oldSolution, solution, CancellationToken.None)); + Assert.False(await EditSession.HasChangesAsync(oldSolution, solution, sourceFilePath: sourcePath, CancellationToken.None)); + + EndDebuggingSession(debuggingSession); + } + + [Fact] + public async Task HasChanges_References() + { + using var _ = CreateWorkspace(out var solution0, out var service); + + solution0 = solution0. + AddTestProject("A", out var projectAId).Solution. + AddTestProject("B", out var projectBId).Solution; + + var debuggingSession = await StartDebuggingSessionAsync(service, solution0); + EnterBreakState(debuggingSession); + + // add metadata reference: + var solution1 = solution0.AddMetadataReference(projectAId, TestReferences.MetadataTests.InterfaceAndClass.CSClasses01); + Assert.True(await EditSession.HasChangesAsync(solution0, solution1, CancellationToken.None)); + + // add project reference: + var solution2 = solution1.AddProjectReferences(projectBId, [new ProjectReference(projectAId)]); + Assert.True(await EditSession.HasChangesAsync(solution1, solution2, CancellationToken.None)); + + // change reference properties: + var solution3 = solution2.WithProjectReferences(projectBId, [new ProjectReference(projectAId, aliases: ["A"])]); + Assert.True(await EditSession.HasChangesAsync(solution2, solution3, CancellationToken.None)); + + EndDebuggingSession(debuggingSession); + } + + [Fact] public async Task Project_Add() + { + var sourceA1 = "class A { void M() { System.Console.WriteLine(1); } }"; + var sourceB1 = "class B { int F() => 1; }"; + + var dir = Temp.CreateDirectory(); + var sourceFileA = dir.CreateFile("a.cs").WriteAllText(sourceA1, Encoding.UTF8); + var sourceFileB = dir.CreateFile("b.cs").WriteAllText(sourceB1, Encoding.UTF8); + + using var _ = CreateWorkspace(out var solution, out var service); + + solution = solution + .AddTestProject("A", out var projectAId) + .AddTestDocument(sourceA1, path: sourceFileA.Path, out var documentAId).Project.Solution; + + EmitAndLoadLibraryToDebuggee(solution.GetRequiredProject(projectAId)); + + var debuggingSession = await StartDebuggingSessionAsync(service, solution); + + // Add project B and a project reference A -> B + solution = solution + .AddTestProject("B", out var projectBId) + .AddTestDocument(sourceB1, path: sourceFileB.Path, out var documentBId).Project.Solution + .AddProjectReference(projectAId, new ProjectReference(projectBId)); + + var runningProjects = ImmutableDictionary.Empty + .Add(projectAId, new RunningProjectInfo() { AllowPartialUpdate = true, RestartWhenChangesHaveNoEffect = false }); + + var results = await debuggingSession.EmitSolutionUpdateAsync(solution, runningProjects, s_noActiveSpans, CancellationToken.None); + + AssertEx.Empty(results.ProjectsToRestart); + AssertEx.Equal([projectBId], results.ProjectsToRebuild); + AssertEx.Equal([projectAId], results.ProjectsToRedeploy); + AssertEx.Equal(ModuleUpdateStatus.Ready, results.ModuleUpdates.Status); + AssertEx.Empty(results.ModuleUpdates.Updates); + + CommitSolutionUpdate(debuggingSession); + EndDebuggingSession(debuggingSession); + } + + [Fact] + public async Task ProjectReference_Add() + { + var sourceA1 = "class A { void M() { System.Console.WriteLine(1); } }"; + var sourceB1 = "class B { int F() => 1; }"; + + var dir = Temp.CreateDirectory(); + var sourceFileA = dir.CreateFile("a.cs").WriteAllText(sourceA1, Encoding.UTF8); + var sourceFileB = dir.CreateFile("b.cs").WriteAllText(sourceB1, Encoding.UTF8); + + using var _ = CreateWorkspace(out var solution, out var service); + + solution = solution + .AddTestProject("A", out var projectAId) + .AddTestDocument(sourceA1, path: sourceFileA.Path, out var documentAId).Project.Solution + .AddTestProject("B", out var projectBId) + .AddTestDocument(sourceB1, path: sourceFileB.Path, out var documentBId).Project.Solution; + + EmitAndLoadLibraryToDebuggee(solution.GetRequiredProject(projectAId)); + EmitAndLoadLibraryToDebuggee(solution.GetRequiredProject(projectBId)); + + var debuggingSession = await StartDebuggingSessionAsync(service, solution); + + // Add project reference A -> B + solution = solution.AddProjectReference(projectAId, new ProjectReference(projectBId)); + + var runningProjects = ImmutableDictionary.Empty + .Add(projectAId, new RunningProjectInfo() { AllowPartialUpdate = true, RestartWhenChangesHaveNoEffect = false }); + + var results = await debuggingSession.EmitSolutionUpdateAsync(solution, runningProjects, s_noActiveSpans, CancellationToken.None); + + AssertEx.Empty(results.ProjectsToRestart); + AssertEx.Empty(results.ProjectsToRebuild); + AssertEx.Equal([projectAId], results.ProjectsToRedeploy); + AssertEx.Equal(ModuleUpdateStatus.None, results.ModuleUpdates.Status); + AssertEx.Empty(results.ModuleUpdates.Updates); + + EndDebuggingSession(debuggingSession); + } + + [Fact] + [WorkItem("https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1371694")] + [WorkItem("https://github.com/dotnet/roslyn/issues/1204")] + [WorkItem("https://github.com/dotnet/roslyn/issues/79423")] + public async Task Project_Add_BinaryAlreadyLoaded() { // Project A: var sourceA1 = "class A { void M() { System.Console.WriteLine(1); } }"; // Project B (baseline, but not loaded into solution): var sourceB1 = "class B { int F() => 1; }"; + var sourceB2 = "class B { virtual int F() => 1; }"; // rude edit - // Additional documents added to B: - var sourceB2 = "class B { int G() => 1; }"; var dir = Temp.CreateDirectory(); var sourceFileA = dir.CreateFile("a.cs").WriteAllText(sourceA1, Encoding.UTF8); var sourceFileB = dir.CreateFile("b.cs").WriteAllText(sourceB1, Encoding.UTF8); using var _ = CreateWorkspace(out var solution, out var service); - solution = AddDefaultTestProject(solution, [sourceA1]); - var documentA1 = solution.Projects.Single().Documents.Single(); - var projectAId = documentA1.Project.Id; + solution = solution + .AddTestProject("A", out var projectAId) + .AddTestDocument(sourceA1, path: sourceFileA.Path, out var documentAId).Project.Solution; + var projectBId = ProjectId.CreateNewId("B"); var mvidA = EmitAndLoadLibraryToDebuggee(projectAId, sourceA1, sourceFilePath: sourceFileA.Path, assemblyName: "A"); @@ -2433,26 +2660,24 @@ public async Task Project_Add() // add project that matches assembly B and update the document: - var documentB2 = solution. + solution = solution. AddTestProject("B", id: projectBId). - AddTestDocument(sourceB2, path: sourceFileB.Path); + AddTestDocument(sourceB2, path: sourceFileB.Path, out var documentBId).Project.Solution; - solution = documentB2.Project.Solution; + var documentB2 = solution.GetRequiredDocument(documentBId); - // TODO: https://github.com/dotnet/roslyn/issues/1204 - // Should return span in document B since the document content matches the PDB. - var baseSpans = await debuggingSession.GetBaseActiveStatementSpansAsync(solution, [documentA1.Id, documentB2.Id], CancellationToken.None); + var baseSpans = await debuggingSession.GetBaseActiveStatementSpansAsync(solution, [documentAId, documentBId], CancellationToken.None); AssertEx.Equal( [ - "", - "(0,21)-(0,22)" + activeLineSpanA1.ToString(), + activeLineSpanB1.ToString(), ], baseSpans.Select(spans => spans.IsEmpty ? "" : string.Join(",", spans.Select(s => s.LineSpan.ToString())))); var trackedActiveSpans = ImmutableArray.Create( new ActiveStatementSpan(new ActiveStatementId(0), activeLineSpanB1, ActiveStatementFlags.MethodUpToDate | ActiveStatementFlags.LeafFrame)); var currentSpans = await debuggingSession.GetAdjustedActiveStatementSpansAsync(documentB2, (_, _, _) => new(trackedActiveSpans), CancellationToken.None); - // TODO: https://github.com/dotnet/roslyn/issues/1204 + // TODO: https://github.com/dotnet/roslyn/issues/79423 // AssertEx.Equal(trackedActiveSpans, currentSpans); Assert.Empty(currentSpans); diff --git a/src/Features/Test/EditAndContinue/EmitSolutionUpdateResultsTests.cs b/src/Features/Test/EditAndContinue/EmitSolutionUpdateResultsTests.cs index bd010741dcf4e..0e93680c4a32b 100644 --- a/src/Features/Test/EditAndContinue/EmitSolutionUpdateResultsTests.cs +++ b/src/Features/Test/EditAndContinue/EmitSolutionUpdateResultsTests.cs @@ -15,7 +15,6 @@ using Microsoft.CodeAnalysis.Test.Utilities; using Microsoft.CodeAnalysis.Text; using Roslyn.Test.Utilities; -using Roslyn.Utilities; using Xunit; namespace Microsoft.CodeAnalysis.EditAndContinue.UnitTests; @@ -143,6 +142,7 @@ public async Task GetHotReloadDiagnostics() ModuleUpdates = new ModuleUpdates(ModuleUpdateStatus.Blocked, Updates: []), ProjectsToRebuild = [], ProjectsToRestart = ImmutableDictionary>.Empty, + ProjectsToRedeploy = [], }; var actual = data.GetAllDiagnostics(); @@ -172,6 +172,7 @@ public void RunningProjects_Updates() solution, CreateValidUpdates(c, d), CreateProjectRudeEdits(blocking: [], noEffect: []), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: false), (b, noEffectRestarts: false)]), out var projectsToRestart, out var projectsToRebuild); @@ -195,6 +196,7 @@ public void RunningProjects_RudeEdits_SingleImpactedRunningProject() solution, CreateValidUpdates(), CreateProjectRudeEdits(blocking: [d], noEffect: []), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: false), (b, noEffectRestarts: false)]), out var projectsToRestart, out var projectsToRebuild); @@ -220,6 +222,7 @@ public void RunningProjects_RudeEdits_MultipleImpactedRunningProjects() solution, CreateValidUpdates(), CreateProjectRudeEdits(blocking: [c], noEffect: []), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: true), (b, noEffectRestarts: false)]), out var projectsToRestart, out var projectsToRebuild); @@ -250,6 +253,7 @@ public void RunningProjects_RudeEdits_NotImpactingRunningProjects() solution, CreateValidUpdates(), CreateProjectRudeEdits(blocking: [d], noEffect: []), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: false)]), out var projectsToRestart, out var projectsToRebuild); @@ -276,6 +280,7 @@ public void RunningProjects_NoEffectEdits_NoEffectRestarts() solution, CreateValidUpdates(c), CreateProjectRudeEdits(blocking: [], noEffect: [c]), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: false), (b, noEffectRestarts: true)]), out var projectsToRestart, out var projectsToRebuild); @@ -309,6 +314,7 @@ public void RunningProjects_NoEffectEdits_BlockingRestartsOnly() solution, CreateValidUpdates(c), CreateProjectRudeEdits(blocking: [], noEffect: [c]), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: false), (b, noEffectRestarts: false)]), out var projectsToRestart, out var projectsToRebuild); @@ -336,6 +342,7 @@ public void RunningProjects_NoEffectEdits_NoImpactedRunningProject() solution, CreateValidUpdates(d), CreateProjectRudeEdits(blocking: [], noEffect: [d]), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: false)]), out var projectsToRestart, out var projectsToRebuild); @@ -359,6 +366,7 @@ public void RunningProjects_NoEffectEditAndRudeEdit_SameProject() solution, CreateValidUpdates(), CreateProjectRudeEdits(blocking: [c], noEffect: [c]), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: false), (b, noEffectRestarts: false)]), out var projectsToRestart, out var projectsToRebuild); @@ -393,6 +401,7 @@ public void RunningProjects_NoEffectEditAndRudeEdit_DifferentProjects(bool allow solution, CreateValidUpdates(p0, q), CreateProjectRudeEdits(blocking: [p1, p2], noEffect: [q]), + addedUnbuiltProjects: [], CreateRunningProjects([(r0, noEffectRestarts: false), (r1, noEffectRestarts: false), (r2, noEffectRestarts: false)], allowPartialUpdate), out var projectsToRestart, out var projectsToRebuild); @@ -427,7 +436,7 @@ public void RunningProjects_NoEffectEditAndRudeEdit_DifferentProjects(bool allow } [Fact] - public void RunningProjects_RudeEditAndUpdate_Dependent() + public void RunningProjects_RudeEditAndUpdate_DependentOnRunningProject() { using var _ = CreateWorkspace(out var solution); @@ -441,6 +450,7 @@ public void RunningProjects_RudeEditAndUpdate_Dependent() solution, CreateValidUpdates(c), CreateProjectRudeEdits(blocking: [d], noEffect: []), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: false), (b, noEffectRestarts: false)]), out var projectsToRestart, out var projectsToRebuild); @@ -456,6 +466,96 @@ public void RunningProjects_RudeEditAndUpdate_Dependent() AssertEx.SetEqual([a, b], projectsToRebuild); } + [Fact] + public void RunningProjects_RudeEditAndUpdate_DependentOnRebuiltProject() + { + using var _ = CreateWorkspace(out var solution); + + solution = solution + .AddTestProject("C", out var c).Solution + .AddTestProject("D", out var d).Solution + .AddTestProject("A", out var a).AddProjectReferences([new(c)]).Solution + .AddTestProject("B", out var b).AddProjectReferences([new(c), new(d)]).Solution; + + EmitSolutionUpdateResults.GetProjectsToRebuildAndRestart( + solution, + CreateValidUpdates(c), + CreateProjectRudeEdits(blocking: [b], noEffect: []), + addedUnbuiltProjects: [], + CreateRunningProjects([(a, noEffectRestarts: false)], allowPartialUpdate: true), + out var projectsToRestart, + out var projectsToRebuild); + + // B has rude edit ==> B has to rebuild + // B -> C and C has change ==> C has to rebuild + // A -> C and A is running ==> A has to restart + AssertEx.Equal( + [ + "A: [B]", + ], Inspect(projectsToRestart)); + + AssertEx.SetEqual([a, b], projectsToRebuild); + } + + [Fact] + public void RunningProjects_AddedProject_NotImpactingRunningProject() + { + using var _ = CreateWorkspace(out var solution); + + solution = solution + .AddTestProject("C", out var c).Solution + .AddTestProject("D", out var d).Solution + .AddTestProject("A", out var a).AddProjectReferences([new(c)]).Solution + .AddTestProject("B", out var b).AddProjectReferences([new(c), new(d)]).Solution; + + EmitSolutionUpdateResults.GetProjectsToRebuildAndRestart( + solution, + CreateValidUpdates(c), + CreateProjectRudeEdits(blocking: [], noEffect: []), + addedUnbuiltProjects: [b], + CreateRunningProjects([(a, noEffectRestarts: false)], allowPartialUpdate: true), + out var projectsToRestart, + out var projectsToRebuild); + + // B isn't built, but doesn't impact a running project ==> does not need to be rebuilt + // B will be considered stale until rebuilt. + Assert.Empty(projectsToRestart); + Assert.Empty(projectsToRebuild); + } + + [Fact] + public void RunningProjects_AddedProject_ImpactingRunningProject() + { + using var _ = CreateWorkspace(out var solution); + + solution = solution + .AddTestProject("C", out var c).Solution + .AddTestProject("D", out var d).Solution + .AddTestProject("A", out var a).AddProjectReferences([new(c)]).Solution + .AddTestProject("B", out var b).AddProjectReferences([new(c), new(d)]).Solution + .AddTestProject("E", out var e).AddProjectReferences([new(b)]).Solution; + + EmitSolutionUpdateResults.GetProjectsToRebuildAndRestart( + solution, + CreateValidUpdates(c), + CreateProjectRudeEdits(blocking: [], noEffect: []), + addedUnbuiltProjects: [b], + CreateRunningProjects([(a, noEffectRestarts: false), (e, noEffectRestarts: false)], allowPartialUpdate: true), + out var projectsToRestart, + out var projectsToRebuild); + + // B isn't built ==> B has to rebuild + // B -> C and C has change ==> C has to rebuild + // A -> C and A is running ==> A has to restart + AssertEx.Equal( + [ + "A: [B]", + "E: [B]", + ], Inspect(projectsToRestart)); + + AssertEx.SetEqual([a, b, e], projectsToRebuild); + } + [Theory] [CombinatorialData] public void RunningProjects_RudeEditAndUpdate_Independent(bool allowPartialUpdate) @@ -472,6 +572,7 @@ public void RunningProjects_RudeEditAndUpdate_Independent(bool allowPartialUpdat solution, CreateValidUpdates(c), CreateProjectRudeEdits(blocking: [d], noEffect: []), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: false), (b, noEffectRestarts: false)], allowPartialUpdate), out var projectsToRestart, out var projectsToRebuild); @@ -510,6 +611,7 @@ public void RunningProjects_NoEffectEditAndUpdate(bool allowPartialUpdate) solution, CreateValidUpdates(c, d), CreateProjectRudeEdits(blocking: [], noEffect: [d]), + addedUnbuiltProjects: [], CreateRunningProjects([(a, noEffectRestarts: false), (b, noEffectRestarts: true)], allowPartialUpdate), out var projectsToRestart, out var projectsToRebuild); @@ -558,6 +660,7 @@ public void RunningProjects_RudeEditAndUpdate_Chain(bool reverse) solution, CreateValidUpdates(reverse ? [p4, p3, p2] : [p2, p3, p4]), CreateProjectRudeEdits(blocking: [p1], noEffect: []), + addedUnbuiltProjects: [], CreateRunningProjects([(r1, noEffectRestarts: false), (r2, noEffectRestarts: false), (r3, noEffectRestarts: false), (r4, noEffectRestarts: false)]), out var projectsToRestart, out var projectsToRebuild); diff --git a/src/Features/Test/EditAndContinue/RemoteEditAndContinueServiceTests.cs b/src/Features/Test/EditAndContinue/RemoteEditAndContinueServiceTests.cs index 943282d04a608..40e1521986f9d 100644 --- a/src/Features/Test/EditAndContinue/RemoteEditAndContinueServiceTests.cs +++ b/src/Features/Test/EditAndContinue/RemoteEditAndContinueServiceTests.cs @@ -34,6 +34,9 @@ private static string Inspect(DiagnosticData d) (!string.IsNullOrWhiteSpace(d.DataLocation.UnmappedFileSpan.Path) ? $" {d.DataLocation.UnmappedFileSpan.Path}({d.DataLocation.UnmappedFileSpan.StartLinePosition.Line}, {d.DataLocation.UnmappedFileSpan.StartLinePosition.Character}, {d.DataLocation.UnmappedFileSpan.EndLinePosition.Line}, {d.DataLocation.UnmappedFileSpan.EndLinePosition.Character}):" : "") + $" {d.Message}"; + private static IEnumerable Inspect(ImmutableDictionary> projects) + => projects.Select(kvp => $"{kvp.Key}: [{string.Join(", ", kvp.Value.Select(p => p.ToString()))}]"); + [Theory, CombinatorialData] public async Task Proxy(TestHost testHost) { @@ -214,8 +217,9 @@ await localWorkspace.ChangeSolutionAsync(localWorkspace.CurrentSolution ModuleUpdates = updates, Diagnostics = diagnostics, SyntaxError = syntaxError, - ProjectsToRebuild = [project.Id], - ProjectsToRestart = ImmutableDictionary>.Empty.Add(project.Id, []), + ProjectsToRebuild = [projectId], + ProjectsToRestart = ImmutableDictionary>.Empty.Add(projectId, [projectId]), + ProjectsToRedeploy = [projectId] }; }; @@ -242,6 +246,10 @@ await localWorkspace.ChangeSolutionAsync(localWorkspace.CurrentSolution Assert.Equal(instructionId1.ILOffset, activeStatements.ILOffset); Assert.Equal(span1, activeStatements.NewSpan.ToLinePositionSpan()); + AssertEx.SequenceEqual([projectId], results.ProjectsToRebuild); + AssertEx.SequenceEqual([$"{projectId}: [{projectId}]"], Inspect(results.ProjectsToRestart)); + AssertEx.SequenceEqual([projectId], results.ProjectsToRedeploy); + // CommitSolutionUpdate await sessionProxy.CommitSolutionUpdateAsync(CancellationToken.None); diff --git a/src/Features/TestUtilities/EditAndContinue/Extensions.cs b/src/Features/TestUtilities/EditAndContinue/Extensions.cs index cbd8857bb9e72..3f7323f21c923 100644 --- a/src/Features/TestUtilities/EditAndContinue/Extensions.cs +++ b/src/Features/TestUtilities/EditAndContinue/Extensions.cs @@ -93,6 +93,10 @@ public static Document AddTestDocument(this Solution solution, ProjectId project ? path : Path.Combine(Path.GetDirectoryName(solution.GetRequiredProject(projectId).FilePath!)!, path)) .GetRequiredDocument(id); + public static Project WithCompilationOptions(this Project project, Func transform) + where TCompilationOptions : CompilationOptions + => project.WithCompilationOptions(transform((TCompilationOptions)(project.CompilationOptions ?? throw ExceptionUtilities.Unreachable()))); + public static Guid CreateProjectTelemetryId(string projectName) { Assert.True(Encoding.UTF8.GetByteCount(projectName) <= 20, "Use shorter project names in tests"); diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Utilities/PooledBuilderExtensions.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Utilities/PooledBuilderExtensions.cs index 01042b6a8acc0..30ff6920299c7 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Utilities/PooledBuilderExtensions.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Utilities/PooledBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -41,12 +42,23 @@ public static Dictionary> ToMultiDictionaryAndFree(th public static ImmutableDictionary> ToImmutableMultiDictionaryAndFree(this PooledDictionary> builders) where K : notnull + => ToImmutableMultiDictionaryAndFree(builders, where: null, whereArg: 0); + + public static ImmutableDictionary> ToImmutableMultiDictionaryAndFree(this PooledDictionary> builders, Func? where, TArg whereArg) + where K : notnull { var result = ImmutableDictionary.CreateBuilder>(); foreach (var (key, items) in builders) { - result.Add(key, items.ToImmutableAndFree()); + if (where == null || where(key, whereArg)) + { + result.Add(key, items.ToImmutableAndFree()); + } + else + { + items.Free(); + } } builders.Free();