Skip to content

Commit c5198d0

Browse files
dibarbetCopilot
andauthored
Restore solution as a whole on auto-load instead of per-project (#84202)
When the standalone language server loads a solution (explicitly or via auto-load), restore the solution in a single `dotnet restore <solution>` rather than restoring each contained project individually. A solution-level restore is significantly faster for large, completely unrestored solutions. Adds a virtual GetPathsToRestore hook on the base project loader and overrides it in LanguageServerProjectSystem to return the solution path when a solution is open. Includes an AutoLoadProjectsTests integration test. ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/dotnet/roslyn/pull/84202) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 258ced1 commit c5198d0

3 files changed

Lines changed: 96 additions & 1 deletion

File tree

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.ProcessHost.UnitTests/Workspaces/AutoLoadProjectsTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,33 @@ public async Task ReportsProgressForExplicitProjectOpen()
133133
await AssertAutoLoadCompletedAsync(testLspServer, GetLoadingProjectsMessage(projectCount: 1), GetLoadedProjectsMessage(projectCount: 1));
134134
}
135135

136+
[Fact]
137+
public async Task RestoresSolutionInsteadOfIndividualProjectsWhenSolutionLoaded()
138+
{
139+
// Note: intentionally not pre-restoring the workspace so that loading the solution triggers an automatic restore.
140+
var workspaceContent = LspWorkspaceContent.Empty
141+
.WithFile("App/App.csproj", ProjectContent)
142+
.WithFile("Nested/Nested.csproj", ProjectContent)
143+
.WithFile("App.sln", CreateSolutionFile("App/App.csproj", "Nested/Nested.csproj"));
144+
145+
await using var testLspServer = await CreateAutoLoadLanguageServerAsync(workspaceContent);
146+
147+
// The single solution file at the root is auto-loaded, which triggers a restore of the unrestored projects.
148+
var restoreUnit = await testLspServer.WorkDoneProgress.WaitForWorkDoneProgressCreation(LanguageServerResources.Restore);
149+
await restoreUnit.WaitForEndAsync();
150+
151+
// Verify that the restore ran against the solution as a whole (a single "Restoring App.sln" stage) rather than
152+
// restoring each contained project individually (which would report a "Restoring <project>.csproj" stage per project).
153+
var restoreStages = restoreUnit.GetProgressReports()
154+
.OfType<WorkDoneProgressReport>()
155+
.Select(report => report.Message)
156+
.Distinct()
157+
.ToArray();
158+
159+
var expectedStage = string.Format(LanguageServerResources.Restoring_0, "App.sln");
160+
Assert.Equal(expectedStage, Assert.Single(restoreStages));
161+
}
162+
136163
private Task<TestLspClient> CreateAutoLoadLanguageServerAsync(LspWorkspaceContent workspaceContent)
137164
=> CreateLanguageServerAsync(
138165
workspaceContent,

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ protected virtual int MaxNodeCount
101101
// Don't overload the machine, so leave some CPU cores open. This was chosen without much supporting evidence, other than that it's still pretty close to max.
102102
=> Math.Max(Environment.ProcessorCount / 2, 1);
103103

104+
/// <summary>
105+
/// Maps the set of project file paths that were determined to need a NuGet restore to the set of paths that restore
106+
/// should actually be invoked on. The base implementation restores each project individually. Derived loaders may
107+
/// override this to coalesce the work, e.g. restoring an entire solution at once instead of restoring each contained
108+
/// project one at a time. This is invoked at restore time (rather than cached) so overrides can consult current,
109+
/// possibly-changed state such as the on-disk contents of the open solution.
110+
/// </summary>
111+
protected virtual ValueTask<ImmutableArray<string>> GetPathsToRestoreAsync(ImmutableArray<string> projectsThatNeedRestore, CancellationToken cancellationToken)
112+
=> new(projectsThatNeedRestore);
113+
104114
protected LanguageServerProjectLoader(
105115
ILspServices lspServices,
106116
IGlobalOptionService globalOptionService,
@@ -212,8 +222,10 @@ private async ValueTask ReloadProjectsAsync(ImmutableSegmentedList<ProjectToLoad
212222

213223
if (GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableAutomaticRestore) && projectsThatNeedRestore.Any())
214224
{
225+
var pathsToRestore = await GetPathsToRestoreAsync(projectsThatNeedRestore, cancellationToken);
226+
215227
// This request blocks to ensure we aren't trying to run a design time build at the same time as a restore.
216-
await ProjectDependencyHelper.RestoreProjectsAsync(_workDoneProgressManager, projectsThatNeedRestore, EnableProgressReporting, _dotnetCliHelper, _logger, cancellationToken);
228+
await ProjectDependencyHelper.RestoreProjectsAsync(_workDoneProgressManager, pathsToRestore, EnableProgressReporting, _dotnetCliHelper, _logger, cancellationToken);
217229
}
218230
}
219231
finally

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,62 @@ public LanguageServerProjectSystem(
7171
_projectFileExtensionRegistry = new ProjectFileExtensionRegistry(new DiagnosticReporter(workspace));
7272
}
7373

74+
/// <summary>
75+
/// When a solution has been opened (either explicitly or via auto-load), restore the solution as a whole rather
76+
/// than restoring each contained project individually. A single solution-level restore is significantly faster than
77+
/// running <c>dotnet restore</c> once per project, which matters most for large, completely unrestored solutions.
78+
/// </summary>
79+
/// <remarks>
80+
/// A solution-level restore only covers the projects contained in the solution. It is possible for a project to be
81+
/// loaded into this project system without being part of the open solution (for example, a project opened on its own
82+
/// via <see cref="OpenProjectsAsync"/>). Such projects are not covered by the solution restore, so they are still
83+
/// restored individually alongside the solution. The solution's project set is re-read from disk here (rather than
84+
/// cached) so that edits to the solution file are always reflected in the restore scope.
85+
/// </remarks>
86+
protected override async ValueTask<ImmutableArray<string>> GetPathsToRestoreAsync(ImmutableArray<string> projectsThatNeedRestore, CancellationToken cancellationToken)
87+
{
88+
var solutionPath = _hostProjectFactory.SolutionPath;
89+
90+
// If no solution is open, restore each project individually.
91+
if (solutionPath is null)
92+
return projectsThatNeedRestore;
93+
94+
// Re-read the solution's current project set so a solution-level restore only collapses projects that are
95+
// actually part of the solution as it exists on disk right now (the set can change if the solution file is
96+
// edited between restores).
97+
ImmutableHashSet<string> solutionProjectPaths;
98+
try
99+
{
100+
var (_, projects) = await SolutionFileReader.ReadSolutionFileAsync(solutionPath, DiagnosticReportingMode.Throw, cancellationToken);
101+
solutionProjectPaths = projects.Select(static p => p.ProjectPath).ToImmutableHashSet(PathUtilities.Comparer);
102+
}
103+
catch (Exception e) when (e is not OperationCanceledException)
104+
{
105+
// If the solution can't be read (for example it was edited into an invalid state), fall back to restoring
106+
// each project individually rather than failing the restore entirely.
107+
_logger.LogWarning(e, "Unable to read solution '{SolutionPath}' to determine restore scope; restoring {ProjectCount} project(s) individually.", solutionPath, projectsThatNeedRestore.Length);
108+
return projectsThatNeedRestore;
109+
}
110+
111+
// Separate out any projects that need a restore but are not part of the open solution. Those are not covered by
112+
// a solution-level restore, so they must still be restored on their own.
113+
var projectsNotInSolution = projectsThatNeedRestore.WhereAsArray(
114+
static (path, solutionProjectPaths) => !solutionProjectPaths.Contains(path), solutionProjectPaths);
115+
116+
// If none of the projects that need a restore are actually part of the solution, restore them individually
117+
// rather than kicking off a solution restore that would not cover any of them.
118+
if (projectsNotInSolution.Length == projectsThatNeedRestore.Length)
119+
return projectsThatNeedRestore;
120+
121+
if (projectsNotInSolution.IsEmpty)
122+
_logger.LogInformation("Restoring solution '{SolutionPath}' instead of {ProjectCount} individual project(s).", solutionPath, projectsThatNeedRestore.Length);
123+
else
124+
_logger.LogInformation("Restoring solution '{SolutionPath}' for its projects, plus {ProjectCount} project(s) not contained in the solution.", solutionPath, projectsNotInSolution.Length);
125+
126+
// Restore the solution as a whole (covering all of its projects), plus any projects outside the solution.
127+
return [solutionPath, .. projectsNotInSolution];
128+
}
129+
74130
public async Task OpenSolutionAsync(string solutionFilePath, IProgress<LSP.WorkDoneProgress>? progressReporter = null)
75131
{
76132
_logger.LogInformation(string.Format(LanguageServerResources.Loading_0, solutionFilePath));

0 commit comments

Comments
 (0)