Skip to content

Commit a972838

Browse files
committed
Support unloading projects
1 parent 4e1662d commit a972838

File tree

7 files changed

+156
-100
lines changed

7 files changed

+156
-100
lines changed

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

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ public FileBasedProgramsProjectSystem(
106106
// For Razor files we need to override the language name to C# as that's what code is generated
107107
var isRazor = languageInformation.LanguageName == "Razor";
108108
var languageName = isRazor ? LanguageNames.CSharp : languageInformation.LanguageName;
109-
var loadedProject = await CreateAndTrackInitialProjectAsync(documentPath, language: languageName);
110109
var documentFileInfo = new DocumentFileInfo(documentPath, logicalPath: documentPath, isLinked: false, isGenerated: false, folders: default);
111110
var projectFileInfo = new ProjectFileInfo()
112111
{
@@ -122,12 +121,20 @@ public FileBasedProgramsProjectSystem(
122121
ContentFilePaths = [],
123122
FileGlobs = []
124123
};
125-
await loadedProject.UpdateWithNewProjectInfoAsync(projectFileInfo, hasAllInformation: false, _logger);
126-
var workspaceProject = ProjectFactory.Workspace.CurrentSolution.GetRequiredProject(loadedProject.ProjectId);
127-
var document = isRazor ? workspaceProject.AdditionalDocuments.Single() : workspaceProject.Documents.Single();
128124

129-
ProjectsToLoadAndReload.AddWork(new ProjectToLoad(documentPath, ProjectGuid: null, ReportTelemetry: true));
130-
loadedProject.NeedsReload += (_, _) => ProjectsToLoadAndReload.AddWork(new ProjectToLoad(documentPath, ProjectGuid: null, ReportTelemetry: false));
125+
var projectSet = AddLoadedProjectSet(documentPath);
126+
Project workspaceProject;
127+
using (await projectSet.Semaphore.DisposableWaitAsync())
128+
{
129+
var loadedProject = await this.CreateAndTrackInitialProjectAsync_NoLock(projectSet, documentPath, language: languageName);
130+
await loadedProject.UpdateWithNewProjectInfoAsync(projectFileInfo, hasAllInformation: false, _logger);
131+
132+
ProjectsToLoadAndReload.AddWork(new ProjectToLoad(documentPath, ProjectGuid: null, ReportTelemetry: true));
133+
loadedProject.NeedsReload += (_, _) => ProjectsToLoadAndReload.AddWork(new ProjectToLoad(documentPath, ProjectGuid: null, ReportTelemetry: false));
134+
workspaceProject = ProjectFactory.Workspace.CurrentSolution.GetRequiredProject(loadedProject.ProjectId);
135+
}
136+
137+
var document = isRazor ? workspaceProject.AdditionalDocuments.Single() : workspaceProject.Documents.Single();
131138

132139
_ = Task.Run(async () =>
133140
{
@@ -139,9 +146,23 @@ public FileBasedProgramsProjectSystem(
139146
return document;
140147
}
141148

142-
public void TryRemoveMiscellaneousDocument(DocumentUri uri, bool removeFromMetadataWorkspace)
149+
public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool removeFromMetadataWorkspace)
143150
{
144-
// support unloading
151+
var documentPath = uri.ParsedUri is { } parsedUri ? ProtocolConversions.GetDocumentFilePathFromUri(parsedUri) : uri.UriString;
152+
await TryUnloadProjectSetAsync(documentPath);
153+
154+
// also do an unload in case this was the non-file scenario
155+
if (removeFromMetadataWorkspace && uri.ParsedUri is not null && _metadataAsSourceFileService.TryRemoveDocumentFromWorkspace(ProtocolConversions.GetDocumentFilePathFromUri(uri.ParsedUri)))
156+
{
157+
return;
158+
}
159+
160+
var matchingDocument = Workspace.CurrentSolution.GetDocumentIds(uri).SingleOrDefault();
161+
if (matchingDocument != null)
162+
{
163+
var project = Workspace.CurrentSolution.GetRequiredProject(matchingDocument.ProjectId);
164+
Workspace.OnProjectRemoved(project.Id);
165+
}
145166
}
146167

147168
protected override async Task<(RemoteProjectFile? projectFile, bool hasAllInformation, BuildHostProcessKind preferred, BuildHostProcessKind actual)> TryLoadProjectAsync(

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ internal sealed class FileBasedProgramsWorkspaceProviderFactory(
3737
{
3838
public ILspMiscellaneousFilesWorkspaceProvider CreateLspMiscellaneousFilesWorkspaceProvider(ILspServices lspServices, HostServices hostServices)
3939
{
40-
// TODO(fbp): check feature flag and maybe give base misc workspace
4140
return new FileBasedProgramsProjectSystem(lspServices, metadataAsSourceFileService, workspaceFactory, fileChangeWatcher, globalOptionService, loggerFactory, listenerProvider, projectLoadTelemetry, serverConfigurationFactory, binlogNamer);
4241
}
4342
}

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

Lines changed: 112 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,9 @@ internal abstract class LanguageServerProjectLoader
4343
private readonly BinlogNamer _binlogNamer;
4444
protected readonly ImmutableDictionary<string, string> AdditionalProperties;
4545

46-
/// <summary>
47-
/// The list of loaded projects in the workspace, keyed by project file path. The outer dictionary is a concurrent dictionary since we may be loading
48-
/// multiple projects at once; the key is a single List we just have a single thread processing any given project file. This is only to be used
49-
/// in <see cref="LoadOrReloadProjectsAsync" /> and downstream calls; any other updating of this (like unloading projects) should be achieved by adding
50-
/// things to the <see cref="ProjectsToLoadAndReload" />.
51-
/// </summary>
52-
private readonly ConcurrentDictionary<string, ImmutableArray<LoadedProject>> _loadedProjects = [];
46+
protected record LoadedProjectSet(List<LoadedProject> LoadedProjects, SemaphoreSlim Semaphore, CancellationTokenSource CancellationTokenSource);
47+
48+
private readonly ConcurrentDictionary<string, LoadedProjectSet> _loadedProjects = [];
5349

5450
protected LanguageServerProjectLoader(
5551
ProjectSystemProjectFactory projectFactory,
@@ -157,103 +153,113 @@ private async Task<bool> LoadOrReloadProjectAsync(ProjectToLoad projectToLoad, T
157153

158154
try
159155
{
160-
var (loadedFile, hasAllInformation, preferredBuildHostKind, actualBuildHostKind) = await TryLoadProjectAsync(buildHostProcessManager, projectPath, cancellationToken);
161-
if (preferredBuildHostKind != actualBuildHostKind)
162-
preferredBuildHostKindThatWeDidNotGet = preferredBuildHostKind;
163-
164-
if (loadedFile is null)
165-
{
166-
_logger.LogWarning($"Unable to load project '{projectPath}'.");
167-
return false;
168-
}
169-
170-
var diagnosticLogItems = await loadedFile.GetDiagnosticLogItemsAsync(cancellationToken);
171-
if (diagnosticLogItems.Any(item => item.Kind is DiagnosticLogItemKind.Error))
156+
if (!_loadedProjects.TryGetValue(projectPath, out var loadedProjectSet) || loadedProjectSet.CancellationTokenSource.IsCancellationRequested)
172157
{
173-
await LogDiagnosticsAsync(diagnosticLogItems);
174-
// We have total failures in evaluation, no point in continuing.
158+
// project was already unloaded or in process of unloading.
175159
return false;
176160
}
177161

178-
var loadedProjectInfos = await loadedFile.GetProjectFileInfosAsync(cancellationToken);
179-
180-
// The out-of-proc build host supports more languages than we may actually have Workspace binaries for, so ensure we can actually process that
181-
// language in-process.
182-
var projectLanguage = loadedProjectInfos.FirstOrDefault()?.Language;
183-
if (projectLanguage != null && ProjectFactory.Workspace.Services.GetLanguageService<ICommandLineParserService>(projectLanguage) == null)
162+
var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(loadedProjectSet.CancellationTokenSource.Token, cancellationToken);
163+
try
184164
{
185-
return false;
186-
}
165+
var (loadedFile, hasAllInformation, preferredBuildHostKind, actualBuildHostKind) = await TryLoadProjectAsync(buildHostProcessManager, projectPath, cancellationToken);
166+
if (preferredBuildHostKind != actualBuildHostKind)
167+
preferredBuildHostKindThatWeDidNotGet = preferredBuildHostKind;
187168

188-
if (!_loadedProjects.TryGetValue(projectPath, out var existingProjects))
189-
existingProjects = [];
169+
if (loadedFile is null)
170+
{
171+
_logger.LogWarning($"Unable to load project '{projectPath}'.");
172+
return false;
173+
}
190174

191-
Dictionary<ProjectFileInfo, ProjectLoadTelemetryReporter.TelemetryInfo> telemetryInfos = [];
192-
var needsRestore = false;
175+
var diagnosticLogItems = await loadedFile.GetDiagnosticLogItemsAsync(cancellationToken);
176+
if (diagnosticLogItems.Any(item => item.Kind is DiagnosticLogItemKind.Error))
177+
{
178+
await LogDiagnosticsAsync(diagnosticLogItems);
179+
// We have total failures in evaluation, no point in continuing.
180+
return false;
181+
}
193182

194-
// We want to remove projects for targets that don't exist anymore; if we update projects we'll remove them from
195-
// this list -- what's left we can then remove.
196-
HashSet<LoadedProject> projectsToRemove = [.. existingProjects];
197-
foreach (var loadedProjectInfo in loadedProjectInfos)
198-
{
199-
var existingProject = existingProjects.FirstOrDefault(p => p.GetTargetFramework() == loadedProjectInfo.TargetFramework);
200-
bool targetNeedsRestore;
201-
ProjectLoadTelemetryReporter.TelemetryInfo targetTelemetryInfo;
183+
var loadedProjectInfos = await loadedFile.GetProjectFileInfosAsync(cancellationToken);
202184

203-
if (existingProject != null)
185+
// The out-of-proc build host supports more languages than we may actually have Workspace binaries for, so ensure we can actually process that
186+
// language in-process.
187+
var projectLanguage = loadedProjectInfos.FirstOrDefault()?.Language;
188+
if (projectLanguage != null && ProjectFactory.Workspace.Services.GetLanguageService<ICommandLineParserService>(projectLanguage) == null)
204189
{
205-
projectsToRemove.Remove(existingProject);
206-
(targetTelemetryInfo, targetNeedsRestore) = await existingProject.UpdateWithNewProjectInfoAsync(loadedProjectInfo, hasAllInformation, _logger);
190+
return false;
207191
}
208-
else
209-
{
210-
var loadedProject = await CreateAndTrackInitialProjectAsync(
211-
projectPath,
212-
loadedProjectInfo.Language,
213-
loadedProjectInfo.TargetFramework,
214-
loadedProjectInfo.IntermediateOutputFilePath);
215-
loadedProject.NeedsReload += (_, _) => ProjectsToLoadAndReload.AddWork(projectToLoad with { ReportTelemetry = false });
216192

217-
(targetTelemetryInfo, targetNeedsRestore) = await loadedProject.UpdateWithNewProjectInfoAsync(loadedProjectInfo, hasAllInformation, _logger);
193+
Dictionary<ProjectFileInfo, ProjectLoadTelemetryReporter.TelemetryInfo> telemetryInfos = [];
194+
var needsRestore = false;
218195

219-
needsRestore |= targetNeedsRestore;
220-
telemetryInfos[loadedProjectInfo] = targetTelemetryInfo with { IsSdkStyle = preferredBuildHostKind == BuildHostProcessKind.NetCore };
196+
using var _ = await loadedProjectSet.Semaphore.DisposableWaitAsync(cancellationToken);
197+
// last chance for someone to cancel out from under us.
198+
if (loadedProjectSet.CancellationTokenSource.IsCancellationRequested)
199+
{
200+
return false;
221201
}
222-
}
223202

224-
if (projectsToRemove.Any())
225-
{
226-
foreach (var project in existingProjects)
203+
var existingProjects = loadedProjectSet.LoadedProjects;
204+
205+
// We want to remove projects for targets that don't exist anymore; if we update projects we'll remove them from
206+
// this list -- what's left we can then remove.
207+
HashSet<LoadedProject> projectsToRemove = [.. existingProjects];
208+
foreach (var loadedProjectInfo in loadedProjectInfos)
227209
{
228-
if (projectsToRemove.Contains(project))
210+
var existingProject = existingProjects.FirstOrDefault(p => p.GetTargetFramework() == loadedProjectInfo.TargetFramework);
211+
bool targetNeedsRestore;
212+
ProjectLoadTelemetryReporter.TelemetryInfo targetTelemetryInfo;
213+
214+
if (existingProject != null)
215+
{
216+
projectsToRemove.Remove(existingProject);
217+
(targetTelemetryInfo, targetNeedsRestore) = await existingProject.UpdateWithNewProjectInfoAsync(loadedProjectInfo, hasAllInformation, _logger);
218+
}
219+
else
229220
{
230-
project.Dispose();
221+
var loadedProject = await CreateAndTrackInitialProjectAsync_NoLock(
222+
loadedProjectSet,
223+
projectPath,
224+
loadedProjectInfo.Language,
225+
loadedProjectInfo.TargetFramework,
226+
loadedProjectInfo.IntermediateOutputFilePath);
227+
loadedProject.NeedsReload += (_, _) => ProjectsToLoadAndReload.AddWork(projectToLoad with { ReportTelemetry = false });
228+
229+
(targetTelemetryInfo, targetNeedsRestore) = await loadedProject.UpdateWithNewProjectInfoAsync(loadedProjectInfo, hasAllInformation, _logger);
230+
231+
needsRestore |= targetNeedsRestore;
232+
telemetryInfos[loadedProjectInfo] = targetTelemetryInfo with { IsSdkStyle = preferredBuildHostKind == BuildHostProcessKind.NetCore };
231233
}
232234
}
233235

234-
_loadedProjects.AddOrUpdate(projectPath,
235-
// We expect the key always continues to be present in the dictionary during this operation.
236-
addValueFactory: (_, _) => throw new InvalidOperationException(),
237-
updateValueFactory: static (_, existingProjects, projectsToRemove) => existingProjects.RemoveRange(projectsToRemove),
238-
factoryArgument: projectsToRemove);
239-
}
236+
foreach (var project in projectsToRemove)
237+
{
238+
project.Dispose();
239+
existingProjects.Remove(project);
240+
}
240241

241-
if (projectToLoad.ReportTelemetry)
242-
{
243-
await _projectLoadTelemetryReporter.ReportProjectLoadTelemetryAsync(telemetryInfos, projectToLoad, cancellationToken);
244-
}
242+
if (projectToLoad.ReportTelemetry)
243+
{
244+
await _projectLoadTelemetryReporter.ReportProjectLoadTelemetryAsync(telemetryInfos, projectToLoad, cancellationToken);
245+
}
245246

246-
diagnosticLogItems = await loadedFile.GetDiagnosticLogItemsAsync(cancellationToken);
247-
if (diagnosticLogItems.Any())
248-
{
249-
await LogDiagnosticsAsync(diagnosticLogItems);
247+
diagnosticLogItems = await loadedFile.GetDiagnosticLogItemsAsync(cancellationToken);
248+
if (diagnosticLogItems.Any())
249+
{
250+
await LogDiagnosticsAsync(diagnosticLogItems);
251+
}
252+
else
253+
{
254+
_logger.LogInformation(string.Format(LanguageServerResources.Successfully_completed_load_of_0, projectPath));
255+
}
256+
257+
return needsRestore;
250258
}
251-
else
259+
catch (OperationCanceledException e) when (e.CancellationToken == linkedTokenSource.Token)
252260
{
253-
_logger.LogInformation(string.Format(LanguageServerResources.Successfully_completed_load_of_0, projectPath));
261+
return false;
254262
}
255-
256-
return needsRestore;
257263
}
258264
catch (Exception e)
259265
{
@@ -291,11 +297,32 @@ async Task LogDiagnosticsAsync(ImmutableArray<DiagnosticLogItem> diagnosticLogIt
291297
/// <summary>
292298
/// Creates a <see cref="LoadedProject"/> which has bare minimum information, but, which documents can be added to and obtained from.
293299
/// </summary>
294-
protected async Task<LoadedProject> CreateAndTrackInitialProjectAsync(
295-
string projectPath,
296-
string language,
297-
string? targetFramework = null,
298-
string? intermediateOutputFilePath = null)
300+
protected LoadedProjectSet AddLoadedProjectSet(string projectPath)
301+
{
302+
var projectSet = new LoadedProjectSet([], new SemaphoreSlim(1), new CancellationTokenSource());
303+
return _loadedProjects.GetOrAdd(projectPath, projectSet);
304+
}
305+
306+
protected async ValueTask TryUnloadProjectSetAsync(string projectPath)
307+
{
308+
if (_loadedProjects.TryRemove(projectPath, out var loadedProjectSet))
309+
{
310+
using var _ = await loadedProjectSet.Semaphore.DisposableWaitAsync();
311+
if (loadedProjectSet.CancellationTokenSource.IsCancellationRequested)
312+
{
313+
// don't need to cancel again.
314+
return;
315+
}
316+
317+
loadedProjectSet.CancellationTokenSource.Cancel();
318+
foreach (var project in loadedProjectSet.LoadedProjects)
319+
{
320+
project.Dispose();
321+
}
322+
}
323+
}
324+
325+
protected async Task<LoadedProject> CreateAndTrackInitialProjectAsync_NoLock(LoadedProjectSet projectSet, string projectPath, string language, string? targetFramework = null, string? intermediateOutputFilePath = null)
299326
{
300327
var projectSystemName = targetFramework is null ? projectPath : $"{projectPath} (${targetFramework})";
301328

@@ -313,7 +340,7 @@ protected async Task<LoadedProject> CreateAndTrackInitialProjectAsync(
313340
_projectSystemHostInfo);
314341

315342
var loadedProject = new LoadedProject(projectSystemProject, ProjectFactory.Workspace.Services.SolutionServices, _fileChangeWatcher, _targetFrameworkManager);
316-
_loadedProjects.AddOrUpdate(projectPath, addValue: [loadedProject], updateValueFactory: (_, arr) => arr.Add(loadedProject));
343+
projectSet.LoadedProjects.Add(loadedProject);
317344
return loadedProject;
318345
}
319346
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ public async Task OpenSolutionAsync(string solutionFilePath)
7474

7575
foreach (var project in await buildHost.GetProjectsInSolutionAsync(solutionFilePath, CancellationToken.None))
7676
{
77+
_ = AddLoadedProjectSet(project.ProjectPath);
7778
ProjectsToLoadAndReload.AddWork(new ProjectToLoad(project.ProjectPath, project.ProjectGuid, ReportTelemetry: true));
7879
}
7980

@@ -90,7 +91,11 @@ public async Task OpenProjectsAsync(ImmutableArray<string> projectFilePaths)
9091

9192
using (await _gate.DisposableWaitAsync())
9293
{
93-
ProjectsToLoadAndReload.AddWork(projectFilePaths.Select(p => new ProjectToLoad(p, ProjectGuid: null, ReportTelemetry: true)));
94+
foreach (var projectPath in projectFilePaths)
95+
{
96+
_ = AddLoadedProjectSet(projectPath);
97+
ProjectsToLoadAndReload.AddWork(new ProjectToLoad(projectPath, ProjectGuid: null, ReportTelemetry: true));
98+
}
9499

95100
// Wait for the in progress batch to complete and send a project initialized notification to the client.
96101
await ProjectsToLoadAndReload.WaitUntilCurrentBatchCompletesAsync();

src/LanguageServer/Protocol/Workspaces/ILspMiscellaneousFilesWorkspaceProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ internal interface ILspMiscellaneousFilesWorkspaceProvider : ILspService
2121
/// async is used here to allow taking locks asynchronously and "relatively fast" stuff like that.
2222
/// </summary>
2323
Task<TextDocument?> AddMiscellaneousDocumentAsync(DocumentUri uri, SourceText documentText, string languageId, ILspLogger logger);
24-
void TryRemoveMiscellaneousDocument(DocumentUri uri, bool removeFromMetadataWorkspace);
24+
ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool removeFromMetadataWorkspace);
2525
}

0 commit comments

Comments
 (0)