Skip to content

Commit d290451

Browse files
Allow LanguageServerProjectSystems to load into more than one workspace (#78975)
Closes #78945 This allows us to place file-based programs in the host workspace so project-to-project references work; truly "miscellaneous files" projects will still stay in the MiscellaneousFiles workspace.
2 parents 2714d92 + a5ff3f7 commit d290451

File tree

8 files changed

+98
-56
lines changed

8 files changed

+98
-56
lines changed

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ internal sealed class FileBasedProgramsProjectSystem : LanguageServerProjectLoad
2727
private readonly ILogger<FileBasedProgramsProjectSystem> _logger;
2828
private readonly IMetadataAsSourceFileService _metadataAsSourceFileService;
2929
private readonly VirtualProjectXmlProvider _projectXmlProvider;
30+
private readonly LanguageServerWorkspaceFactory _workspaceFactory;
3031

3132
public FileBasedProgramsProjectSystem(
3233
ILspServices lspServices,
@@ -41,7 +42,6 @@ public FileBasedProgramsProjectSystem(
4142
ServerConfigurationFactory serverConfigurationFactory,
4243
IBinLogPathProvider binLogPathProvider)
4344
: base(
44-
workspaceFactory.FileBasedProgramsProjectFactory,
4545
workspaceFactory.TargetFrameworkManager,
4646
workspaceFactory.ProjectSystemHostInfo,
4747
fileChangeWatcher,
@@ -56,12 +56,20 @@ public FileBasedProgramsProjectSystem(
5656
_logger = loggerFactory.CreateLogger<FileBasedProgramsProjectSystem>();
5757
_metadataAsSourceFileService = metadataAsSourceFileService;
5858
_projectXmlProvider = projectXmlProvider;
59+
_workspaceFactory = workspaceFactory;
5960
}
6061

61-
public Workspace Workspace => ProjectFactory.Workspace;
62-
6362
private string GetDocumentFilePath(DocumentUri uri) => uri.ParsedUri is { } parsedUri ? ProtocolConversions.GetDocumentFilePathFromUri(parsedUri) : uri.UriString;
6463

64+
public async ValueTask<bool> IsMiscellaneousFilesDocumentAsync(TextDocument document, CancellationToken cancellationToken)
65+
{
66+
// There are two cases here: if it's a primordial document, it'll be in the MiscellaneousFilesWorkspace and thus we definitely know it's
67+
// a miscellaneous file. Otherwise, it might be a file-based program that we loaded in the main workspace; in this case, the project's path
68+
// is also the source file path, and that's what we consider the 'project' path that is loaded.
69+
return document.Project.Solution.Workspace == _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace ||
70+
document.Project.FilePath is not null && await IsProjectLoadedAsync(document.Project.FilePath, cancellationToken);
71+
}
72+
6573
public async ValueTask<TextDocument?> AddMiscellaneousDocumentAsync(DocumentUri uri, SourceText documentText, string languageId, ILspLogger logger)
6674
{
6775
var documentFilePath = GetDocumentFilePath(uri);
@@ -80,7 +88,7 @@ public FileBasedProgramsProjectSystem(
8088
var doDesignTimeBuild = uri.ParsedUri?.IsFile is true
8189
&& primordialDoc.Project.Language == LanguageNames.CSharp
8290
&& GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms);
83-
await BeginLoadingProjectWithPrimordialAsync(primordialDoc.FilePath, primordialProjectId: primordialDoc.Project.Id, doDesignTimeBuild);
91+
await BeginLoadingProjectWithPrimordialAsync(primordialDoc.FilePath, _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory, primordialProjectId: primordialDoc.Project.Id, doDesignTimeBuild);
8492

8593
return primordialDoc;
8694

@@ -92,12 +100,12 @@ TextDocument AddPrimordialDocument(DocumentUri uri, SourceText documentText, str
92100
Contract.Fail($"Could not find language information for {uri} with absolute path {documentFilePath}");
93101
}
94102

95-
var workspace = Workspace;
103+
var workspace = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace;
96104
var sourceTextLoader = new SourceTextLoader(documentText, documentFilePath);
97105
var projectInfo = MiscellaneousFileUtilities.CreateMiscellaneousProjectInfoForDocument(
98106
workspace, documentFilePath, sourceTextLoader, languageInformation, documentText.ChecksumAlgorithm, workspace.Services.SolutionServices, []);
99107

100-
ProjectFactory.ApplyChangeToWorkspace(workspace => workspace.OnProjectAdded(projectInfo));
108+
_workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.ApplyChangeToWorkspace(workspace => workspace.OnProjectAdded(projectInfo));
101109

102110
// https://github.com/dotnet/roslyn/pull/78267
103111
// Work around an issue where opening a Razor file in the misc workspace causes a crash.
@@ -145,13 +153,21 @@ public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool
145153
// This is necessary in order to get msbuild to apply the standard c# props/targets to the project.
146154
var virtualProjectPath = VirtualProjectXmlProvider.GetVirtualProjectPath(documentPath);
147155

148-
var loader = ProjectFactory.CreateFileTextLoader(documentPath);
156+
var loader = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.CreateFileTextLoader(documentPath);
149157
var textAndVersion = await loader.LoadTextAsync(new LoadTextOptions(SourceHashAlgorithms.Default), cancellationToken);
150158
var isFileBasedProgram = VirtualProjectXmlProvider.IsFileBasedProgram(documentPath, textAndVersion.Text);
151159

152160
const BuildHostProcessKind buildHostKind = BuildHostProcessKind.NetCore;
153161
var buildHost = await buildHostProcessManager.GetBuildHostAsync(buildHostKind, cancellationToken);
154162
var loadedFile = await buildHost.LoadProjectAsync(virtualProjectPath, virtualProjectContent, languageName: LanguageNames.CSharp, cancellationToken);
155-
return new RemoteProjectLoadResult(loadedFile, HasAllInformation: isFileBasedProgram, Preferred: buildHostKind, Actual: buildHostKind);
163+
164+
return new RemoteProjectLoadResult(
165+
loadedFile,
166+
// If it's a proper file based program, we'll put it in the main host workspace factory since we want cross-project references to work.
167+
// Otherwise, we'll keep it in miscellaneous files.
168+
ProjectFactory: isFileBasedProgram ? _workspaceFactory.HostProjectFactory : _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory,
169+
HasAllInformation: isFileBasedProgram,
170+
Preferred: buildHostKind,
171+
Actual: buildHostKind);
156172
}
157173
}

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

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ internal abstract class LanguageServerProjectLoader
2929
{
3030
private readonly AsyncBatchingWorkQueue<ProjectToLoad> _projectsToReload;
3131

32-
protected readonly ProjectSystemProjectFactory ProjectFactory;
3332
private readonly ProjectTargetFrameworkManager _targetFrameworkManager;
3433
private readonly ProjectSystemHostInfo _projectSystemHostInfo;
3534
private readonly IFileChangeWatcher _fileChangeWatcher;
@@ -66,11 +65,15 @@ private ProjectLoadState() { }
6665
/// Represents a project which has not yet had a design-time build performed for it,
6766
/// and which has an associated "primordial project" in the workspace.
6867
/// </summary>
68+
/// <param name="PrimordialProjectFactory">
69+
/// The project factory for the workspace that the primordial project lives within. This
70+
/// factory was not used to create the project, but still needs to be used during removal to avoid locking issues.
71+
/// </param>
6972
/// <param name="PrimordialProjectId">
7073
/// ID of the project which LSP uses to fulfill requests until the first design-time build is complete.
7174
/// The project with this ID is removed from the workspace when unloading or when transitioning to <see cref="LoadedTargets"/> state.
7275
/// </param>
73-
public sealed record Primordial(ProjectId PrimordialProjectId) : ProjectLoadState;
76+
public sealed record Primordial(ProjectSystemProjectFactory PrimordialProjectFactory, ProjectId PrimordialProjectId) : ProjectLoadState;
7477

7578
/// <summary>
7679
/// Represents a project for which we have loaded zero or more targets.
@@ -83,7 +86,6 @@ public sealed record LoadedTargets(ImmutableArray<LoadedProject> LoadedProjectTa
8386
}
8487

8588
protected LanguageServerProjectLoader(
86-
ProjectSystemProjectFactory projectFactory,
8789
ProjectTargetFrameworkManager targetFrameworkManager,
8890
ProjectSystemHostInfo projectSystemHostInfo,
8991
IFileChangeWatcher fileChangeWatcher,
@@ -94,7 +96,6 @@ protected LanguageServerProjectLoader(
9496
ServerConfigurationFactory serverConfigurationFactory,
9597
IBinLogPathProvider binLogPathProvider)
9698
{
97-
ProjectFactory = projectFactory;
9899
_targetFrameworkManager = targetFrameworkManager;
99100
_projectSystemHostInfo = projectSystemHostInfo;
100101
_fileChangeWatcher = fileChangeWatcher;
@@ -103,7 +104,6 @@ protected LanguageServerProjectLoader(
103104
_logger = loggerFactory.CreateLogger(nameof(LanguageServerProjectLoader));
104105
_projectLoadTelemetryReporter = projectLoadTelemetry;
105106
_binLogPathProvider = binLogPathProvider;
106-
var workspace = projectFactory.Workspace;
107107
var razorDesignTimePath = serverConfigurationFactory.ServerConfiguration?.RazorDesignTimePath;
108108

109109
AdditionalProperties = razorDesignTimePath is null
@@ -174,7 +174,7 @@ private async ValueTask ReloadProjectsAsync(ImmutableSegmentedList<ProjectToLoad
174174
}
175175
}
176176

177-
protected sealed record RemoteProjectLoadResult(RemoteProjectFile ProjectFile, bool HasAllInformation, BuildHostProcessKind Preferred, BuildHostProcessKind Actual);
177+
protected sealed record RemoteProjectLoadResult(RemoteProjectFile ProjectFile, ProjectSystemProjectFactory ProjectFactory, bool HasAllInformation, BuildHostProcessKind Preferred, BuildHostProcessKind Actual);
178178

179179
/// <summary>Loads a project in the MSBuild host.</summary>
180180
/// <remarks>Caller needs to catch exceptions to avoid bringing down the project loader queue.</remarks>
@@ -209,7 +209,7 @@ private async Task<bool> ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr
209209
return false;
210210
}
211211

212-
(RemoteProjectFile remoteProjectFile, bool hasAllInformation, BuildHostProcessKind preferredBuildHostKind, BuildHostProcessKind actualBuildHostKind) = remoteProjectLoadResult;
212+
(RemoteProjectFile remoteProjectFile, ProjectSystemProjectFactory projectFactory, bool hasAllInformation, BuildHostProcessKind preferredBuildHostKind, BuildHostProcessKind actualBuildHostKind) = remoteProjectLoadResult;
213213
if (preferredBuildHostKind != actualBuildHostKind)
214214
preferredBuildHostKindThatWeDidNotGet = preferredBuildHostKind;
215215

@@ -226,7 +226,7 @@ private async Task<bool> ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr
226226
// The out-of-proc build host supports more languages than we may actually have Workspace binaries for, so ensure we can actually process that
227227
// language in-process.
228228
var projectLanguage = loadedProjectInfos.FirstOrDefault()?.Language;
229-
if (projectLanguage != null && ProjectFactory.Workspace.Services.GetLanguageService<ICommandLineParserService>(projectLanguage) == null)
229+
if (projectLanguage != null && projectFactory.Workspace.Services.GetLanguageService<ICommandLineParserService>(projectLanguage) == null)
230230
{
231231
return false;
232232
}
@@ -246,7 +246,7 @@ private async Task<bool> ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr
246246
var newProjectTargetsBuilder = ArrayBuilder<LoadedProject>.GetInstance(loadedProjectInfos.Length);
247247
foreach (var loadedProjectInfo in loadedProjectInfos)
248248
{
249-
var (target, targetAlreadyExists) = await GetOrCreateProjectTargetAsync(previousProjectTargets, loadedProjectInfo);
249+
var (target, targetAlreadyExists) = await GetOrCreateProjectTargetAsync(previousProjectTargets, projectFactory, loadedProjectInfo);
250250
newProjectTargetsBuilder.Add(target);
251251

252252
var (targetTelemetryInfo, targetNeedsRestore) = await target.UpdateWithNewProjectInfoAsync(loadedProjectInfo, hasAllInformation, _logger);
@@ -272,15 +272,19 @@ private async Task<bool> ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr
272272
await _projectLoadTelemetryReporter.ReportProjectLoadTelemetryAsync(telemetryInfos, projectToLoad, cancellationToken);
273273
}
274274

275-
if (currentLoadState is ProjectLoadState.Primordial(var projectId))
275+
if (currentLoadState is ProjectLoadState.Primordial(var primordialProjectFactory, var projectId))
276276
{
277277
// Remove the primordial project now that the design-time build pass is finished. This ensures that
278278
// we have the new project in place before we remove the primordial project; otherwise for
279279
// Miscellaneous Files we could have a case where we'd get another request to create a project
280280
// for the project we're currently processing.
281-
await ProjectFactory.ApplyChangeToWorkspaceAsync(workspace => workspace.OnProjectRemoved(projectId), cancellationToken);
281+
await primordialProjectFactory.ApplyChangeToWorkspaceAsync(workspace => workspace.OnProjectRemoved(projectId), cancellationToken);
282282
}
283283

284+
// At this point we expect that all the loaded projects are now in the project factory returned, and any previous ones have been removed.
285+
// this is a Debug.Assert() because if this expectation fails, the user's probably still in a state where things will work just fine;
286+
// throwing here would mean we don't remember the LoadedProjects we created, and the next update will create more and things will get really broken.
287+
Debug.Assert(newProjectTargets.All(target => target.ProjectFactory == projectFactory));
284288
_loadedProjects[projectPath] = new ProjectLoadState.LoadedTargets(newProjectTargets);
285289
}
286290

@@ -306,9 +310,9 @@ private async Task<bool> ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr
306310
return false;
307311
}
308312

309-
async Task<(LoadedProject, bool alreadyExists)> GetOrCreateProjectTargetAsync(ImmutableArray<LoadedProject> previousProjectTargets, ProjectFileInfo loadedProjectInfo)
313+
async Task<(LoadedProject, bool alreadyExists)> GetOrCreateProjectTargetAsync(ImmutableArray<LoadedProject> previousProjectTargets, ProjectSystemProjectFactory projectFactory, ProjectFileInfo loadedProjectInfo)
310314
{
311-
var existingProject = previousProjectTargets.FirstOrDefault(p => p.GetTargetFramework() == loadedProjectInfo.TargetFramework);
315+
var existingProject = previousProjectTargets.FirstOrDefault(p => p.GetTargetFramework() == loadedProjectInfo.TargetFramework && p.ProjectFactory == projectFactory);
312316
if (existingProject != null)
313317
{
314318
return (existingProject, alreadyExists: true);
@@ -324,13 +328,13 @@ private async Task<bool> ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr
324328
CompilationOutputAssemblyFilePath = loadedProjectInfo.IntermediateOutputFilePath,
325329
};
326330

327-
var projectSystemProject = await ProjectFactory.CreateAndAddToWorkspaceAsync(
331+
var projectSystemProject = await projectFactory.CreateAndAddToWorkspaceAsync(
328332
projectSystemName,
329333
loadedProjectInfo.Language,
330334
projectCreationInfo,
331335
_projectSystemHostInfo);
332336

333-
var loadedProject = new LoadedProject(projectSystemProject, ProjectFactory.Workspace.Services.SolutionServices, _fileChangeWatcher, _targetFrameworkManager);
337+
var loadedProject = new LoadedProject(projectSystemProject, projectFactory, _fileChangeWatcher, _targetFrameworkManager);
334338
loadedProject.NeedsReload += (_, _) => _projectsToReload.AddWork(projectToLoad with { ReportTelemetry = false });
335339
return (loadedProject, alreadyExists: false);
336340
}
@@ -358,14 +362,22 @@ async Task LogDiagnosticsAsync(ImmutableArray<DiagnosticLogItem> diagnosticLogIt
358362
}
359363
}
360364

365+
protected async ValueTask<bool> IsProjectLoadedAsync(string projectPath, CancellationToken cancellationToken)
366+
{
367+
using (await _gate.DisposableWaitAsync(cancellationToken))
368+
{
369+
return _loadedProjects.ContainsKey(projectPath);
370+
}
371+
}
372+
361373
/// <summary>
362374
/// Begins loading a project with an associated primordial project. Must not be called for a project which has already begun loading.
363375
/// </summary>
364376
/// <param name="doDesignTimeBuild">
365377
/// If <see langword="true"/>, initiates a design-time build now, and starts file watchers to repeat the design-time build on relevant changes.
366378
/// If <see langword="false"/>, only tracks the primordial project.
367379
/// </param>
368-
protected async ValueTask BeginLoadingProjectWithPrimordialAsync(string projectPath, ProjectId primordialProjectId, bool doDesignTimeBuild)
380+
protected async ValueTask BeginLoadingProjectWithPrimordialAsync(string projectPath, ProjectSystemProjectFactory primordialProjectFactory, ProjectId primordialProjectId, bool doDesignTimeBuild)
369381
{
370382
using (await _gate.DisposableWaitAsync(CancellationToken.None))
371383
{
@@ -377,7 +389,7 @@ protected async ValueTask BeginLoadingProjectWithPrimordialAsync(string projectP
377389
Contract.Fail($"Cannot begin loading project '{projectPath}' because it has already begun loading.");
378390
}
379391

380-
_loadedProjects.Add(projectPath, new ProjectLoadState.Primordial(primordialProjectId));
392+
_loadedProjects.Add(projectPath, new ProjectLoadState.Primordial(primordialProjectFactory, primordialProjectId));
381393
if (doDesignTimeBuild)
382394
{
383395
_projectsToReload.AddWork(new ProjectToLoad(projectPath, ProjectGuid: null, ReportTelemetry: true));
@@ -416,9 +428,9 @@ protected async ValueTask UnloadProjectAsync(string projectPath)
416428
return;
417429
}
418430

419-
if (loadState is ProjectLoadState.Primordial(var projectId))
431+
if (loadState is ProjectLoadState.Primordial(var projectFactory, var projectId))
420432
{
421-
await ProjectFactory.ApplyChangeToWorkspaceAsync(workspace => workspace.OnProjectRemoved(projectId));
433+
await projectFactory.ApplyChangeToWorkspaceAsync(workspace => workspace.OnProjectRemoved(projectId));
422434
}
423435
else if (loadState is ProjectLoadState.LoadedTargets(var existingProjects))
424436
{

0 commit comments

Comments
 (0)