Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use a named pipe to communicate projectinfo in vscode #10521

Merged
merged 34 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5b3f408
Update ProjectInfo synchronization to use a named pipe instead of file
ryzngard Jun 20, 2024
1dbd46d
Clean up and move some logic into extension methods
ryzngard Jun 21, 2024
1718f43
Use pooled arrays
ryzngard Jun 21, 2024
cbe4b94
Remove file based project info settings
ryzngard Jun 22, 2024
b872a10
Woops
ryzngard Jun 22, 2024
16c6010
Make asynchronous
ryzngard Jun 22, 2024
c5a5faa
Fix tests and add serialization test
ryzngard Jun 22, 2024
d641652
Update src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Projec…
ryzngard Jun 24, 2024
c48aac9
PR feedback. Allocate less. Bump some logs to information. Dispose st…
ryzngard Jun 24, 2024
c32f25f
Merge branch 'named_pipe' of https://github.com/ryzngard/razor into n…
ryzngard Jun 24, 2024
98c99c6
Add ownership validation
ryzngard Jun 24, 2024
7c006a8
Stackalloc
ryzngard Jun 24, 2024
4a445c2
Stackalloc for writing size
ryzngard Jun 24, 2024
2985b2e
usings...
ryzngard Jun 24, 2024
7cde45f
Dispose named pipe
ryzngard Jun 24, 2024
3aee1bf
Clean up and move to abstract to make testing easier
ryzngard Jun 25, 2024
c8fe2dc
PR feedback and publicapi fixes
ryzngard Jun 25, 2024
2a675c3
Update src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Projec…
ryzngard Jun 25, 2024
6c4c79e
Move to RZLS
ryzngard Jun 26, 2024
6717529
Remove because cut/paste doesn't in VS
ryzngard Jun 26, 2024
537b019
Be more clear about async work. Use local functions to help scope
ryzngard Jun 26, 2024
e915417
Copilot forgot my using!
ryzngard Jun 26, 2024
60f3661
Clean up loop expectation
ryzngard Jun 26, 2024
1979f0e
Fix tests
ryzngard Jun 27, 2024
05ab5ff
Update PublicAPI.Unshipped.txt
ryzngard Jun 27, 2024
3fa3205
Productive discussion! Let's gooooo!
ryzngard Jun 27, 2024
22c52d5
LKJDLKSJFLKSJDFLKJDFLJSLFKDJSLDKFJ:LKj:LjKL:DJl;jak;ljfsdkl;ajfa;lksj…
ryzngard Jun 27, 2024
7103fb1
Be more clear about disposal state
ryzngard Jun 27, 2024
d548c02
Use disposal token
ryzngard Jun 27, 2024
e6cc53a
PR feedback
ryzngard Jun 27, 2024
36dc320
Merge branch 'main' into named_pipe
ryzngard Jun 27, 2024
81de399
Update src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.Roslyn…
ryzngard Jun 27, 2024
88a359c
Feedback
ryzngard Jun 27, 2024
1f9ae6d
Merge branch 'named_pipe' of https://github.com/ryzngard/razor into n…
ryzngard Jun 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#nullable enable
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.Dispose() -> void
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.EnsureInitialized(Microsoft.CodeAnalysis.Workspace! workspace, string! projectInfoFileName) -> void
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.NotifyDynamicFile(Microsoft.CodeAnalysis.ProjectId! projectId) -> void
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.RazorWorkspaceListener(Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.EnsureInitialized(Microsoft.CodeAnalysis.Workspace! workspace, string! pipeName) -> void
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.RazorWorkspaceListener(Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListenerBase
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListenerBase.NotifyDynamicFile(Microsoft.CodeAnalysis.ProjectId! projectId) -> void
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListenerBase.RazorWorkspaceListenerBase(Microsoft.Extensions.Logging.ILogger! logger) -> void
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,31 @@

namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace;

internal static class RazorProjectInfoSerializer
internal static class RazorProjectInfoFactory
{
private static readonly StringComparison s_stringComparison;

static RazorProjectInfoSerializer()
static RazorProjectInfoFactory()
{
s_stringComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
}

public static async Task SerializeAsync(Project project, string configurationFileName, ILogger? logger, CancellationToken cancellationToken)
public static async Task<RazorProjectInfo?> ConvertAsync(Project project, ILogger? logger, CancellationToken cancellationToken)
{
var projectPath = Path.GetDirectoryName(project.FilePath);
if (projectPath is null)
{
logger?.LogTrace("projectPath is null, skipping writing info for {projectId}", project.Id);
return;
logger?.LogInformation("projectPath is null, skip conversion for {projectId}", project.Id);
return null;
}

var intermediateOutputPath = Path.GetDirectoryName(project.CompilationOutputInfo.AssemblyPath);
if (intermediateOutputPath is null)
{
logger?.LogTrace("intermediatePath is null, skipping writing info for {projectId}", project.Id);
return;
logger?.LogInformation("intermediatePath is null, skip conversion for {projectId}", project.Id);
return null;
}

// First, lets get the documents, because if there aren't any, we can skip out early
Expand All @@ -52,8 +52,16 @@ public static async Task SerializeAsync(Project project, string configurationFil
// Not a razor project
if (documents.Length == 0)
{
logger?.LogTrace("No razor documents for {projectId}", project.Id);
return;
if (project.DocumentIds.Count == 0)
{
logger?.LogInformation("No razor documents for {projectId}", project.Id);
}
else
{
logger?.LogTrace("No documents in {projectId}", project.Id);
}

return null;
}

var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default;
Expand Down Expand Up @@ -86,18 +94,14 @@ public static async Task SerializeAsync(Project project, string configurationFil

var projectWorkspaceState = ProjectWorkspaceState.Create(tagHelpers, csharpLanguageVersion);

var configurationFilePath = Path.Combine(intermediateOutputPath, configurationFileName);

var projectInfo = new RazorProjectInfo(
return new RazorProjectInfo(
projectKey: new ProjectKey(intermediateOutputPath),
filePath: project.FilePath!,
configuration: configuration,
rootNamespace: defaultNamespace,
displayName: project.Name,
projectWorkspaceState: projectWorkspaceState,
documents: documents);

WriteToFile(configurationFilePath, projectInfo, logger);
}

private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfigOptionsProvider options, ILogger? logger, out string defaultNamespace)
Expand Down Expand Up @@ -126,38 +130,6 @@ private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfi
return razorConfiguration;
}

private static void WriteToFile(string configurationFilePath, RazorProjectInfo projectInfo, ILogger? logger)
{
// We need to avoid having an incomplete file at any point, but our
// project configuration is large enough that it will be written as multiple operations.
var tempFilePath = string.Concat(configurationFilePath, ".temp");
var tempFileInfo = new FileInfo(tempFilePath);

if (tempFileInfo.Exists)
{
// This could be caused by failures during serialization or early process termination.
logger?.LogTrace("deleting existing file {filePath}", tempFilePath);
tempFileInfo.Delete();
}

// This needs to be in explicit brackets because the operation needs to be completed
// by the time we move the temp file into its place
using (var stream = tempFileInfo.Create())
{
projectInfo.SerializeTo(stream);
}

var fileInfo = new FileInfo(configurationFilePath);
if (fileInfo.Exists)
{
logger?.LogTrace("deleting existing file {filePath}", configurationFilePath);
fileInfo.Delete();
}

logger?.LogTrace("Moving {tmpPath} to {newPath}", tempFilePath, configurationFilePath);
File.Move(tempFileInfo.FullName, configurationFilePath);
}

internal static ImmutableArray<DocumentSnapshotHandle> GetDocuments(Project project, string projectPath)
{
using var documents = new PooledArrayBuilder<DocumentSnapshotHandle>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,198 +1,35 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Collections.Immutable;
using Microsoft.AspNetCore.Razor.Utilities;
using System.IO.Pipes;
using Microsoft.CodeAnalysis;
using Microsoft.Extensions.Logging;

namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace;

public class RazorWorkspaceListener : IDisposable
public sealed class RazorWorkspaceListener : RazorWorkspaceListenerBase
{
private static readonly TimeSpan s_debounceTime = TimeSpan.FromMilliseconds(500);

private readonly ILogger _logger;

private string? _projectInfoFileName;
private Workspace? _workspace;

// Use an immutable dictionary for ImmutableInterlocked operations. The value isn't checked, just
// the existance of the key so work is only done for projects with dynamic files.
private ImmutableDictionary<ProjectId, bool> _projectsWithDynamicFile = ImmutableDictionary<ProjectId, bool>.Empty;
private readonly CancellationTokenSource _disposeTokenSource = new();
private readonly AsyncBatchingWorkQueue<ProjectId> _workQueue;

public RazorWorkspaceListener(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger(nameof(RazorWorkspaceListener));
_workQueue = new(TimeSpan.FromMilliseconds(500), UpdateCurrentProjectsAsync, EqualityComparer<ProjectId>.Default, _disposeTokenSource.Token);
}

public void Dispose()
{
if (_workspace is not null)
{
_workspace.WorkspaceChanged -= Workspace_WorkspaceChanged;
}

if (_disposeTokenSource.IsCancellationRequested)
{
return;
}

_disposeTokenSource.Cancel();
_disposeTokenSource.Dispose();
}

public void EnsureInitialized(Workspace workspace, string projectInfoFileName)
{
// Make sure we don't hook up the event handler multiple times
if (_projectInfoFileName is not null)
{
return;
}

_projectInfoFileName = projectInfoFileName;
_workspace = workspace;
_workspace.WorkspaceChanged += Workspace_WorkspaceChanged;
}

public void NotifyDynamicFile(ProjectId projectId)
{
// Since there is no "un-notify" API to indicate that callers no longer care about a project, it's entirely
// possible that by the time we get notified, a project might have been removed from the workspace. Whilst
// that wouldn't cause any issues we may as well avoid creating a task scheduler.
if (_workspace is null || !_workspace.CurrentSolution.ContainsProject(projectId))
{
return;
}

ImmutableInterlocked.GetOrAdd(ref _projectsWithDynamicFile, projectId, static (_) => true);

// Schedule a task, in case adding a dynamic file is the last thing that happens
_logger.LogTrace("{projectId} scheduling task due to dynamic file", projectId);
_workQueue.AddWork(projectId);
}

private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs e)
{
switch (e.Kind)
{
case WorkspaceChangeKind.SolutionChanged:
case WorkspaceChangeKind.SolutionReloaded:
foreach (var project in e.NewSolution.Projects)
{
EnqueueUpdate(project);
}

break;

case WorkspaceChangeKind.SolutionAdded:
foreach (var project in e.NewSolution.Projects)
{
EnqueueUpdate(project);
}

break;

case WorkspaceChangeKind.ProjectReloaded:
EnqueueUpdate(e.NewSolution.GetProject(e.ProjectId));
break;

case WorkspaceChangeKind.ProjectRemoved:
RemoveProject(e.ProjectId.AssumeNotNull());
break;

case WorkspaceChangeKind.ProjectAdded:
case WorkspaceChangeKind.ProjectChanged:
case WorkspaceChangeKind.DocumentAdded:
case WorkspaceChangeKind.DocumentRemoved:
case WorkspaceChangeKind.DocumentReloaded:
case WorkspaceChangeKind.DocumentChanged:
case WorkspaceChangeKind.AdditionalDocumentAdded:
case WorkspaceChangeKind.AdditionalDocumentRemoved:
case WorkspaceChangeKind.AdditionalDocumentReloaded:
case WorkspaceChangeKind.AdditionalDocumentChanged:
case WorkspaceChangeKind.DocumentInfoChanged:
case WorkspaceChangeKind.AnalyzerConfigDocumentAdded:
case WorkspaceChangeKind.AnalyzerConfigDocumentRemoved:
case WorkspaceChangeKind.AnalyzerConfigDocumentReloaded:
case WorkspaceChangeKind.AnalyzerConfigDocumentChanged:
var projectId = e.ProjectId ?? e.DocumentId?.ProjectId;
if (projectId is not null)
{
EnqueueUpdate(e.NewSolution.GetProject(projectId));
}

break;

case WorkspaceChangeKind.SolutionCleared:
case WorkspaceChangeKind.SolutionRemoved:
foreach (var project in e.OldSolution.Projects)
{
RemoveProject(project.Id);
}

break;

default:
break;
}
public RazorWorkspaceListener(ILoggerFactory loggerFactory) : base(loggerFactory.CreateLogger(nameof(RazorWorkspaceListener)))
{
}

private void RemoveProject(ProjectId projectId)
public void EnsureInitialized(Workspace workspace, string pipeName)
jaredpar marked this conversation as resolved.
Show resolved Hide resolved
{
ImmutableInterlocked.TryRemove(ref _projectsWithDynamicFile, projectId, out var _);
EnsureInitialized(workspace, () => new NamedPipeServerStream(
pipeName,
PipeDirection.Out,
maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte,
PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous));
}

private void EnqueueUpdate(Project? project)
private protected override Task CheckConnectionAsync(Stream stream, CancellationToken cancellationToken)
{
if (_projectInfoFileName is null ||
project is not
{
Language: LanguageNames.CSharp
})
if (stream is NamedPipeServerStream { IsConnected: false } namedPipe)
{
return;
return namedPipe.WaitForConnectionAsync(cancellationToken);
}

// Don't queue work for projects that don't have a dynamic file
if (!_projectsWithDynamicFile.TryGetValue(project.Id, out var _))
{
return;
}

var projectId = project.Id;
_workQueue.AddWork(projectId);
}

private async ValueTask UpdateCurrentProjectsAsync(ImmutableArray<ProjectId> projectIds, CancellationToken cancellationToken)
{
var solution = _workspace.AssumeNotNull().CurrentSolution;

foreach (var projectId in projectIds)
{
if (_disposeTokenSource.IsCancellationRequested)
{
return;
}

var project = solution.GetProject(projectId);
if (project is null)
{
_logger?.LogTrace("Project {projectId} is not in workspace", projectId);
continue;
}

await SerializeProjectAsync(project, solution, cancellationToken).ConfigureAwait(false);
}
}

// private protected for testing
private protected virtual Task SerializeProjectAsync(Project project, Solution solution, CancellationToken cancellationToken)
{
_logger?.LogTrace("Serializing information for {projectId}", project.Id);
return RazorProjectInfoSerializer.SerializeAsync(project, _projectInfoFileName.AssumeNotNull(), _logger, cancellationToken);
return Task.CompletedTask;
ryzngard marked this conversation as resolved.
Show resolved Hide resolved
}
}
Loading