Skip to content

Commit 5457702

Browse files
authored
Move RazorWorkspaceListener to AsyncBatchingWorkQueue (#10480)
In Linux + VS Code when creating a new project we're seeing work get dropped from the queue that would result in up to date information being written to disk. Looking at the logs + the bin file it's noticeable that the correct project information isn't there, and that results in Razor behaving poorly by not having the correct files in the correct projects, and often times lacking the accurate amount of taghelpers. Switching to AsyncBatchingWorkQueue seems to improve reliability here. I also added more trace logging to help identify when information is being written, when files are moving, etc. A try/catch was added to make sure we log in case of an exception as well. There are still issues on Linux, but this solves part of them.
1 parent 8f1aa3e commit 5457702

File tree

19 files changed

+89
-146
lines changed

19 files changed

+89
-146
lines changed

src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/PublicAPI.Unshipped.txt

+1-2
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,4 @@ Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener
33
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.Dispose() -> void
44
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.EnsureInitialized(Microsoft.CodeAnalysis.Workspace! workspace, string! projectInfoFileName) -> void
55
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.NotifyDynamicFile(Microsoft.CodeAnalysis.ProjectId! projectId) -> void
6-
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.RazorWorkspaceListener(Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void
7-
virtual Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.SerializeProjectAsync(Microsoft.CodeAnalysis.ProjectId! projectId, System.Threading.CancellationToken ct) -> System.Threading.Tasks.Task!
6+
Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.RazorWorkspaceListener(Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void

src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorProjectInfoSerializer.cs

+13-5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using Microsoft.CodeAnalysis.CSharp;
1616
using Microsoft.CodeAnalysis.Diagnostics;
1717
using Microsoft.CodeAnalysis.Razor;
18+
using Microsoft.Extensions.Logging;
1819

1920
namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace;
2021

@@ -29,17 +30,19 @@ static RazorProjectInfoSerializer()
2930
: StringComparison.OrdinalIgnoreCase;
3031
}
3132

32-
public static async Task SerializeAsync(Project project, string configurationFileName, CancellationToken cancellationToken)
33+
public static async Task SerializeAsync(Project project, string configurationFileName, ILogger? logger, CancellationToken cancellationToken)
3334
{
3435
var projectPath = Path.GetDirectoryName(project.FilePath);
3536
if (projectPath is null)
3637
{
38+
logger?.LogTrace("projectPath is null, skipping writing info for {projectId}", project.Id);
3739
return;
3840
}
3941

4042
var intermediateOutputPath = Path.GetDirectoryName(project.CompilationOutputInfo.AssemblyPath);
4143
if (intermediateOutputPath is null)
4244
{
45+
logger?.LogTrace("intermediatePath is null, skipping writing info for {projectId}", project.Id);
4346
return;
4447
}
4548

@@ -49,13 +52,14 @@ public static async Task SerializeAsync(Project project, string configurationFil
4952
// Not a razor project
5053
if (documents.Length == 0)
5154
{
55+
logger?.LogTrace("No razor documents for {projectId}", project.Id);
5256
return;
5357
}
5458

5559
var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default;
5660

5761
var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider;
58-
var configuration = ComputeRazorConfigurationOptions(options, out var defaultNamespace);
62+
var configuration = ComputeRazorConfigurationOptions(options, logger, out var defaultNamespace);
5963

6064
var fileSystem = RazorProjectFileSystem.Create(projectPath);
6165

@@ -93,10 +97,10 @@ public static async Task SerializeAsync(Project project, string configurationFil
9397
projectWorkspaceState: projectWorkspaceState,
9498
documents: documents);
9599

96-
WriteToFile(configurationFilePath, projectInfo);
100+
WriteToFile(configurationFilePath, projectInfo, logger);
97101
}
98102

99-
private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfigOptionsProvider options, out string defaultNamespace)
103+
private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfigOptionsProvider options, ILogger? logger, out string defaultNamespace)
100104
{
101105
// See RazorSourceGenerator.RazorProviders.cs
102106

@@ -111,6 +115,7 @@ private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfi
111115
if (!globalOptions.TryGetValue("build_property.RazorLangVersion", out var razorLanguageVersionString) ||
112116
!RazorLanguageVersion.TryParse(razorLanguageVersionString, out var razorLanguageVersion))
113117
{
118+
logger?.LogTrace("Using default of latest language version");
114119
razorLanguageVersion = RazorLanguageVersion.Latest;
115120
}
116121

@@ -121,7 +126,7 @@ private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfi
121126
return razorConfiguration;
122127
}
123128

124-
private static void WriteToFile(string configurationFilePath, RazorProjectInfo projectInfo)
129+
private static void WriteToFile(string configurationFilePath, RazorProjectInfo projectInfo, ILogger? logger)
125130
{
126131
// We need to avoid having an incomplete file at any point, but our
127132
// project configuration is large enough that it will be written as multiple operations.
@@ -131,6 +136,7 @@ private static void WriteToFile(string configurationFilePath, RazorProjectInfo p
131136
if (tempFileInfo.Exists)
132137
{
133138
// This could be caused by failures during serialization or early process termination.
139+
logger?.LogTrace("deleting existing file {filePath}", tempFilePath);
134140
tempFileInfo.Delete();
135141
}
136142

@@ -144,9 +150,11 @@ private static void WriteToFile(string configurationFilePath, RazorProjectInfo p
144150
var fileInfo = new FileInfo(configurationFilePath);
145151
if (fileInfo.Exists)
146152
{
153+
logger?.LogTrace("deleting existing file {filePath}", configurationFilePath);
147154
fileInfo.Delete();
148155
}
149156

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

src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListener.cs

+61-55
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT license. See License.txt in the project root for license information.
33

44
using System.Collections.Immutable;
5+
using Microsoft.AspNetCore.Razor.Utilities;
56
using Microsoft.CodeAnalysis;
67
using Microsoft.Extensions.Logging;
78

@@ -15,11 +16,33 @@ public class RazorWorkspaceListener : IDisposable
1516

1617
private string? _projectInfoFileName;
1718
private Workspace? _workspace;
18-
private ImmutableDictionary<ProjectId, TaskDelayScheduler> _workQueues = ImmutableDictionary<ProjectId, TaskDelayScheduler>.Empty;
19+
20+
// Use an immutable dictionary for ImmutableInterlocked operations. The value isn't checked, just
21+
// the existance of the key so work is only done for projects with dynamic files.
22+
private ImmutableDictionary<ProjectId, bool> _projectsWithDynamicFile = ImmutableDictionary<ProjectId, bool>.Empty;
23+
private readonly CancellationTokenSource _disposeTokenSource = new();
24+
private readonly AsyncBatchingWorkQueue<ProjectId> _workQueue;
1925

2026
public RazorWorkspaceListener(ILoggerFactory loggerFactory)
2127
{
2228
_logger = loggerFactory.CreateLogger(nameof(RazorWorkspaceListener));
29+
_workQueue = new(TimeSpan.FromMilliseconds(500), UpdateCurrentProjectsAsync, EqualityComparer<ProjectId>.Default, _disposeTokenSource.Token);
30+
}
31+
32+
public void Dispose()
33+
{
34+
if (_workspace is not null)
35+
{
36+
_workspace.WorkspaceChanged -= Workspace_WorkspaceChanged;
37+
}
38+
39+
if (_disposeTokenSource.IsCancellationRequested)
40+
{
41+
return;
42+
}
43+
44+
_disposeTokenSource.Cancel();
45+
_disposeTokenSource.Dispose();
2346
}
2447

2548
public void EnsureInitialized(Workspace workspace, string projectInfoFileName)
@@ -45,14 +68,11 @@ public void NotifyDynamicFile(ProjectId projectId)
4568
return;
4669
}
4770

48-
// We expect this to be called multiple times per project so a no-op update operation seems like a better choice
49-
// than constructing a new TaskDelayScheduler each time, and using the TryAdd method, which doesn't support a
50-
// valueFactory argument.
51-
var scheduler = ImmutableInterlocked.AddOrUpdate(ref _workQueues, projectId, static _ => new TaskDelayScheduler(s_debounceTime, CancellationToken.None), static (_, val) => val);
71+
ImmutableInterlocked.GetOrAdd(ref _projectsWithDynamicFile, projectId, static (_) => true);
5272

5373
// Schedule a task, in case adding a dynamic file is the last thing that happens
5474
_logger.LogTrace("{projectId} scheduling task due to dynamic file", projectId);
55-
scheduler.ScheduleAsyncTask(ct => SerializeProjectAsync(projectId, ct), CancellationToken.None);
75+
_workQueue.AddWork(projectId);
5676
}
5777

5878
private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs e)
@@ -61,11 +81,6 @@ private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs
6181
{
6282
case WorkspaceChangeKind.SolutionChanged:
6383
case WorkspaceChangeKind.SolutionReloaded:
64-
foreach (var project in e.OldSolution.Projects)
65-
{
66-
RemoveProject(project);
67-
}
68-
6984
foreach (var project in e.NewSolution.Projects)
7085
{
7186
EnqueueUpdate(project);
@@ -81,15 +96,14 @@ private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs
8196

8297
break;
8398

84-
case WorkspaceChangeKind.ProjectRemoved:
85-
RemoveProject(e.OldSolution.GetProject(e.ProjectId));
86-
break;
87-
8899
case WorkspaceChangeKind.ProjectReloaded:
89-
RemoveProject(e.OldSolution.GetProject(e.ProjectId));
90100
EnqueueUpdate(e.NewSolution.GetProject(e.ProjectId));
91101
break;
92102

103+
case WorkspaceChangeKind.ProjectRemoved:
104+
RemoveProject(e.ProjectId.AssumeNotNull());
105+
break;
106+
93107
case WorkspaceChangeKind.ProjectAdded:
94108
case WorkspaceChangeKind.ProjectChanged:
95109
case WorkspaceChangeKind.DocumentAdded:
@@ -112,30 +126,24 @@ private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs
112126
}
113127

114128
break;
129+
115130
case WorkspaceChangeKind.SolutionCleared:
116131
case WorkspaceChangeKind.SolutionRemoved:
117132
foreach (var project in e.OldSolution.Projects)
118133
{
119-
RemoveProject(project);
134+
RemoveProject(project.Id);
120135
}
121136

122137
break;
138+
123139
default:
124140
break;
125141
}
126142
}
127143

128-
private void RemoveProject(Project? project)
144+
private void RemoveProject(ProjectId projectId)
129145
{
130-
if (project is null)
131-
{
132-
return;
133-
}
134-
135-
if (ImmutableInterlocked.TryRemove(ref _workQueues, project.Id, out var scheduler))
136-
{
137-
scheduler.Dispose();
138-
}
146+
ImmutableInterlocked.TryRemove(ref _projectsWithDynamicFile, projectId, out var _);
139147
}
140148

141149
private void EnqueueUpdate(Project? project)
@@ -149,44 +157,42 @@ project is not
149157
return;
150158
}
151159

152-
var projectId = project.Id;
153-
if (_workQueues.TryGetValue(projectId, out var scheduler))
160+
// Don't queue work for projects that don't have a dynamic file
161+
if (!_projectsWithDynamicFile.TryGetValue(project.Id, out var _))
154162
{
155-
_logger.LogTrace("{projectId} scheduling task due to workspace event", projectId);
156-
157-
scheduler.ScheduleAsyncTask(ct => SerializeProjectAsync(projectId, ct), CancellationToken.None);
163+
return;
158164
}
165+
166+
var projectId = project.Id;
167+
_workQueue.AddWork(projectId);
159168
}
160169

161-
// Protected for testing
162-
protected virtual Task SerializeProjectAsync(ProjectId projectId, CancellationToken ct)
170+
private async ValueTask UpdateCurrentProjectsAsync(ImmutableArray<ProjectId> projectIds, CancellationToken cancellationToken)
163171
{
164-
if (_projectInfoFileName is null || _workspace is null)
165-
{
166-
return Task.CompletedTask;
167-
}
172+
var solution = _workspace.AssumeNotNull().CurrentSolution;
168173

169-
var project = _workspace.CurrentSolution.GetProject(projectId);
170-
if (project is null)
174+
foreach (var projectId in projectIds)
171175
{
172-
return Task.CompletedTask;
173-
}
176+
if (_disposeTokenSource.IsCancellationRequested)
177+
{
178+
return;
179+
}
174180

175-
_logger.LogTrace("{projectId} writing json file", projectId);
176-
return RazorProjectInfoSerializer.SerializeAsync(project, _projectInfoFileName, ct);
177-
}
181+
var project = solution.GetProject(projectId);
182+
if (project is null)
183+
{
184+
_logger?.LogTrace("Project {projectId} is not in workspace", projectId);
185+
continue;
186+
}
178187

179-
public void Dispose()
180-
{
181-
if (_workspace is not null)
182-
{
183-
_workspace.WorkspaceChanged -= Workspace_WorkspaceChanged;
188+
await SerializeProjectAsync(project, solution, cancellationToken).ConfigureAwait(false);
184189
}
190+
}
185191

186-
var queues = Interlocked.Exchange(ref _workQueues, ImmutableDictionary<ProjectId, TaskDelayScheduler>.Empty);
187-
foreach (var (_, value) in queues)
188-
{
189-
value.Dispose();
190-
}
192+
// private protected for testing
193+
private protected virtual Task SerializeProjectAsync(Project project, Solution solution, CancellationToken cancellationToken)
194+
{
195+
_logger?.LogTrace("Serializing information for {projectId}", project.Id);
196+
return RazorProjectInfoSerializer.SerializeAsync(project, _projectInfoFileName.AssumeNotNull(), _logger, cancellationToken);
191197
}
192198
}

src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/TaskDelayScheduler.cs

-66
This file was deleted.

src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Diagnostics/RazorDiagnosticsPublisher.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
using Microsoft.AspNetCore.Razor.Language;
1212
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
1313
using Microsoft.AspNetCore.Razor.PooledObjects;
14+
using Microsoft.AspNetCore.Razor.Utilities;
1415
using Microsoft.CodeAnalysis.Razor;
1516
using Microsoft.CodeAnalysis.Razor.Logging;
1617
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
1718
using Microsoft.CodeAnalysis.Razor.Protocol;
18-
using Microsoft.CodeAnalysis.Razor.Utilities;
1919
using Microsoft.CodeAnalysis.Razor.Workspaces;
2020
using Microsoft.VisualStudio.LanguageServer.Protocol;
2121
using Microsoft.VisualStudio.Threading;

src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/OpenDocumentGenerator.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
using System.Collections.Immutable;
77
using System.Threading;
88
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.Razor.Utilities;
910
using Microsoft.CodeAnalysis.Razor.Logging;
1011
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
11-
using Microsoft.CodeAnalysis.Razor.Utilities;
1212
using Microsoft.CodeAnalysis.Razor.Workspaces;
1313

1414
namespace Microsoft.AspNetCore.Razor.LanguageServer;

0 commit comments

Comments
 (0)