Skip to content

Commit e278610

Browse files
Remove ProjectSnapshotManagerDispatcher from ProjectConfigurationStateSynchronizer (#10283)
This change removes `ProjectSnapshotManagerDispatcher` usage from `ProjectConfigurationStateSynchronizer` and `ProjectConfigurationFileChangeDetector`. Ultimately, a lot of this code will go away once #10173 is the default behavior. As part of this change, I updated `ProjectConfigurationStateSynchronizer` to use an `AsyncBatchingWorkQueue`. In my opinion, this is a huge improvement over the previous implementation.
2 parents 2a185e5 + 93f8537 commit e278610

11 files changed

+518
-525
lines changed

SpellingExclusions.dic

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
csharp
22
cshtml
33
csproj
4+
deserializer
45
hresult
56
microsoft
67
vsls

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

Lines changed: 35 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Collections.Immutable;
67
using System.IO;
78
using System.Threading;
89
using System.Threading.Tasks;
@@ -14,66 +15,42 @@
1415

1516
namespace Microsoft.AspNetCore.Razor.LanguageServer;
1617

17-
internal class ProjectConfigurationFileChangeDetector : IFileChangeDetector
18+
internal class ProjectConfigurationFileChangeDetector(
19+
IEnumerable<IProjectConfigurationFileChangeListener> listeners,
20+
LanguageServerFeatureOptions options,
21+
ILoggerFactory loggerFactory) : IFileChangeDetector
1822
{
19-
private readonly ProjectSnapshotManagerDispatcher _dispatcher;
20-
private readonly IEnumerable<IProjectConfigurationFileChangeListener> _listeners;
21-
private readonly LanguageServerFeatureOptions _options;
22-
private readonly ILogger _logger;
23-
private FileSystemWatcher? _watcher;
24-
25-
private static readonly IReadOnlyCollection<string> s_ignoredDirectories = new string[]
26-
{
23+
private static readonly ImmutableArray<string> s_ignoredDirectories =
24+
[
2725
"node_modules",
2826
"bin",
2927
".vs",
30-
};
28+
];
3129

32-
public ProjectConfigurationFileChangeDetector(
33-
ProjectSnapshotManagerDispatcher dispatcher,
34-
IEnumerable<IProjectConfigurationFileChangeListener> listeners,
35-
LanguageServerFeatureOptions options,
36-
ILoggerFactory loggerFactory)
37-
{
38-
if (loggerFactory is null)
39-
{
40-
throw new ArgumentNullException(nameof(loggerFactory));
41-
}
30+
private readonly ImmutableArray<IProjectConfigurationFileChangeListener> _listeners = listeners.ToImmutableArray();
31+
private readonly LanguageServerFeatureOptions _options = options;
32+
private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<ProjectConfigurationFileChangeDetector>();
4233

43-
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
44-
_listeners = listeners ?? throw new ArgumentNullException(nameof(listeners));
45-
_options = options ?? throw new ArgumentNullException(nameof(options));
46-
_logger = loggerFactory.GetOrCreateLogger<ProjectConfigurationFileChangeDetector>();
47-
}
34+
private FileSystemWatcher? _watcher;
4835

49-
public async Task StartAsync(string workspaceDirectory, CancellationToken cancellationToken)
36+
public Task StartAsync(string workspaceDirectory, CancellationToken cancellationToken)
5037
{
51-
if (workspaceDirectory is null)
52-
{
53-
throw new ArgumentNullException(nameof(workspaceDirectory));
54-
}
55-
5638
// Dive through existing project configuration files and fabricate "added" events so listeners can accurately listen to state changes for them.
5739

5840
workspaceDirectory = FilePathNormalizer.Normalize(workspaceDirectory);
5941
var existingConfigurationFiles = GetExistingConfigurationFiles(workspaceDirectory);
6042

6143
_logger.LogDebug($"Triggering events for existing project configuration files");
62-
await _dispatcher.RunAsync(() =>
44+
45+
foreach (var configurationFilePath in existingConfigurationFiles)
6346
{
64-
foreach (var configurationFilePath in existingConfigurationFiles)
65-
{
66-
FileSystemWatcher_ProjectConfigurationFileEvent(configurationFilePath, RazorFileChangeKind.Added);
67-
}
68-
}, cancellationToken).ConfigureAwait(false);
47+
NotifyListeners(new(configurationFilePath, RazorFileChangeKind.Added));
48+
}
6949

7050
// This is an entry point for testing
71-
OnInitializationFinished();
72-
73-
if (cancellationToken.IsCancellationRequested)
51+
if (!InitializeFileWatchers)
7452
{
75-
// Client cancelled connection, no need to setup any file watchers. Server is about to tear down.
76-
return;
53+
return Task.CompletedTask;
7754
}
7855

7956
try
@@ -85,23 +62,19 @@ await _dispatcher.RunAsync(() =>
8562
Directory.CreateDirectory(workspaceDirectory);
8663
}
8764
}
88-
catch (OperationCanceledException)
89-
{
90-
return;
91-
}
9265
catch (Exception ex)
9366
{
9467
// Directory.Exists will throw on things like long paths
9568
_logger.LogError(ex, $"Failed validating that file watcher would be successful for '{workspaceDirectory}'");
9669

9770
// No point continuing because the FileSystemWatcher constructor would just throw too.
98-
return;
71+
return Task.FromException(ex);
9972
}
10073

10174
if (cancellationToken.IsCancellationRequested)
10275
{
10376
// Client cancelled connection, no need to setup any file watchers. Server is about to tear down.
104-
return;
77+
return Task.FromCanceled(cancellationToken);
10578
}
10679

10780
_logger.LogInformation($"Starting configuration file change detector for '{workspaceDirectory}'");
@@ -111,26 +84,28 @@ await _dispatcher.RunAsync(() =>
11184
IncludeSubdirectories = true,
11285
};
11386

114-
_watcher.Created += (sender, args) => FileSystemWatcher_ProjectConfigurationFileEvent_Background(args.FullPath, RazorFileChangeKind.Added);
115-
_watcher.Deleted += (sender, args) => FileSystemWatcher_ProjectConfigurationFileEvent_Background(args.FullPath, RazorFileChangeKind.Removed);
116-
_watcher.Changed += (sender, args) => FileSystemWatcher_ProjectConfigurationFileEvent_Background(args.FullPath, RazorFileChangeKind.Changed);
87+
_watcher.Created += (sender, args) => NotifyListeners(args.FullPath, RazorFileChangeKind.Added);
88+
_watcher.Deleted += (sender, args) => NotifyListeners(args.FullPath, RazorFileChangeKind.Removed);
89+
_watcher.Changed += (sender, args) => NotifyListeners(args.FullPath, RazorFileChangeKind.Changed);
11790
_watcher.Renamed += (sender, args) =>
11891
{
11992
// Translate file renames into remove / add
12093

12194
if (args.OldFullPath.EndsWith(_options.ProjectConfigurationFileName, FilePathComparison.Instance))
12295
{
12396
// Renaming from project configuration file to something else. Just remove the configuration file.
124-
FileSystemWatcher_ProjectConfigurationFileEvent_Background(args.OldFullPath, RazorFileChangeKind.Removed);
97+
NotifyListeners(args.OldFullPath, RazorFileChangeKind.Removed);
12598
}
12699
else if (args.FullPath.EndsWith(_options.ProjectConfigurationFileName, FilePathComparison.Instance))
127100
{
128101
// Renaming from a non-project configuration file file to a real one. Just add the configuration file.
129-
FileSystemWatcher_ProjectConfigurationFileEvent_Background(args.FullPath, RazorFileChangeKind.Added);
102+
NotifyListeners(args.FullPath, RazorFileChangeKind.Added);
130103
}
131104
};
132105

133106
_watcher.EnableRaisingEvents = true;
107+
108+
return Task.CompletedTask;
134109
}
135110

136111
public void Stop()
@@ -142,33 +117,28 @@ public void Stop()
142117
}
143118

144119
// Protected virtual for testing
145-
protected virtual void OnInitializationFinished()
146-
{
147-
}
120+
protected virtual bool InitializeFileWatchers => true;
148121

149122
// Protected virtual for testing
150-
protected virtual IEnumerable<string> GetExistingConfigurationFiles(string workspaceDirectory)
123+
protected virtual ImmutableArray<string> GetExistingConfigurationFiles(string workspaceDirectory)
151124
{
152125
return DirectoryHelper.GetFilteredFiles(
153126
workspaceDirectory,
154127
_options.ProjectConfigurationFileName,
155128
s_ignoredDirectories,
156-
logger: _logger);
129+
logger: _logger).ToImmutableArray();
157130
}
158131

159-
private void FileSystemWatcher_ProjectConfigurationFileEvent_Background(string physicalFilePath, RazorFileChangeKind kind)
132+
private void NotifyListeners(string physicalFilePath, RazorFileChangeKind kind)
160133
{
161-
_ = _dispatcher.RunAsync(
162-
() => FileSystemWatcher_ProjectConfigurationFileEvent(physicalFilePath, kind),
163-
CancellationToken.None);
134+
NotifyListeners(new(physicalFilePath, kind));
164135
}
165136

166-
private void FileSystemWatcher_ProjectConfigurationFileEvent(string physicalFilePath, RazorFileChangeKind kind)
137+
private void NotifyListeners(ProjectConfigurationFileChangeEventArgs args)
167138
{
168-
var args = new ProjectConfigurationFileChangeEventArgs(physicalFilePath, kind);
169139
foreach (var listener in _listeners)
170140
{
171-
_logger.LogDebug($"Notifying listener '{listener}' of config file path '{physicalFilePath}' change with kind '{kind}'");
141+
_logger.LogDebug($"Notifying listener '{listener}' of config file path '{args.ConfigurationFilePath}' change with kind '{args.Kind}'");
172142
listener.ProjectConfigurationFileChanged(args);
173143
}
174144
}

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

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,20 @@
1212

1313
namespace Microsoft.AspNetCore.Razor.LanguageServer;
1414

15-
internal sealed class ProjectConfigurationFileChangeEventArgs : EventArgs
15+
internal sealed class ProjectConfigurationFileChangeEventArgs(
16+
string configurationFilePath,
17+
RazorFileChangeKind kind,
18+
IRazorProjectInfoDeserializer? deserializer = null) : EventArgs
1619
{
17-
public string ConfigurationFilePath { get; }
18-
public RazorFileChangeKind Kind { get; }
20+
public string ConfigurationFilePath { get; } = configurationFilePath;
21+
public RazorFileChangeKind Kind { get; } = kind;
1922

20-
private readonly IRazorProjectInfoDeserializer _deserializer;
23+
private readonly IRazorProjectInfoDeserializer _deserializer = deserializer ?? RazorProjectInfoDeserializer.Instance;
2124
private RazorProjectInfo? _projectInfo;
22-
private readonly object _gate;
25+
private readonly object _gate = new();
2326
private bool _deserialized;
2427

25-
public ProjectConfigurationFileChangeEventArgs(
26-
string configurationFilePath,
27-
RazorFileChangeKind kind,
28-
IRazorProjectInfoDeserializer? projectInfoDeserializer = null)
29-
{
30-
ConfigurationFilePath = configurationFilePath ?? throw new ArgumentNullException(nameof(configurationFilePath));
31-
Kind = kind;
32-
_deserializer = projectInfoDeserializer ?? RazorProjectInfoDeserializer.Instance;
33-
_gate = new object();
34-
}
35-
36-
public bool TryDeserialize(LanguageServerFeatureOptions languageServerFeatureOptions, [NotNullWhen(true)] out RazorProjectInfo? projectInfo)
28+
public bool TryDeserialize(LanguageServerFeatureOptions options, [NotNullWhen(true)] out RazorProjectInfo? projectInfo)
3729
{
3830
if (Kind == RazorFileChangeKind.Removed)
3931
{
@@ -65,7 +57,7 @@ public bool TryDeserialize(LanguageServerFeatureOptions languageServerFeatureOpt
6557
{
6658
Configuration = deserializedProjectInfo.Configuration with
6759
{
68-
LanguageServerFlags = languageServerFeatureOptions.ToLanguageServerFlags()
60+
LanguageServerFlags = options.ToLanguageServerFlags()
6961
}
7062
};
7163

@@ -79,15 +71,10 @@ public bool TryDeserialize(LanguageServerFeatureOptions languageServerFeatureOpt
7971
return false;
8072
}
8173
}
82-
}
8374

84-
projectInfo = _projectInfo;
85-
if (projectInfo is null)
86-
{
87-
// Deserialization failed
88-
return false;
75+
projectInfo = _projectInfo;
8976
}
9077

91-
return true;
78+
return projectInfo is not null;
9279
}
9380
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT license. See License.txt in the project root for license information.
3+
4+
using System.Collections.Generic;
5+
using Microsoft.CodeAnalysis.Razor;
6+
7+
namespace Microsoft.AspNetCore.Razor.LanguageServer;
8+
9+
internal partial class ProjectConfigurationStateSynchronizer
10+
{
11+
private sealed class Comparer : IEqualityComparer<Work>
12+
{
13+
public static readonly Comparer Instance = new();
14+
15+
private Comparer()
16+
{
17+
}
18+
19+
public bool Equals(Work? x, Work? y)
20+
{
21+
if (x is null)
22+
{
23+
return y is null;
24+
}
25+
else if (y is null)
26+
{
27+
return x is null;
28+
}
29+
30+
// For purposes of removing duplicates from batches, two Work instances
31+
// are equal only if their identifying properties are equal. So, only
32+
// configuration file paths and project keys.
33+
34+
if (!FilePathComparer.Instance.Equals(x.ConfigurationFilePath, y.ConfigurationFilePath))
35+
{
36+
return false;
37+
}
38+
39+
return (x, y) switch
40+
{
41+
(AddProject, AddProject) => true,
42+
43+
(ResetProject { ProjectKey: var keyX },
44+
ResetProject { ProjectKey: var keyY })
45+
=> keyX == keyY,
46+
47+
(UpdateProject { ProjectKey: var keyX },
48+
UpdateProject { ProjectKey: var keyY })
49+
=> keyX == keyY,
50+
51+
_ => false,
52+
};
53+
}
54+
55+
public int GetHashCode(Work obj)
56+
=> obj.GetHashCode();
57+
}
58+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT license. See License.txt in the project root for license information.
3+
4+
using System.Threading.Tasks;
5+
6+
namespace Microsoft.AspNetCore.Razor.LanguageServer;
7+
8+
internal partial class ProjectConfigurationStateSynchronizer
9+
{
10+
internal TestAccessor GetTestAccessor() => new(this);
11+
12+
internal sealed class TestAccessor(ProjectConfigurationStateSynchronizer instance)
13+
{
14+
public Task WaitUntilCurrentBatchCompletesAsync()
15+
=> instance._workQueue.WaitUntilCurrentBatchCompletesAsync();
16+
}
17+
}

0 commit comments

Comments
 (0)