Skip to content

Commit 3b9ee1b

Browse files
Use AsyncBatchingWorkQueue in RazorFileChangeDetector
1 parent f6e4255 commit 3b9ee1b

File tree

3 files changed

+186
-245
lines changed

3 files changed

+186
-245
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 Microsoft.AspNetCore.Razor.LanguageServer.Common;
5+
using System.Threading.Tasks;
6+
using System;
7+
8+
namespace Microsoft.AspNetCore.Razor.LanguageServer;
9+
10+
internal partial class RazorFileChangeDetector
11+
{
12+
internal TestAccessor GetTestAccessor() => new(this);
13+
14+
internal sealed class TestAccessor(RazorFileChangeDetector instance)
15+
{
16+
public void AddWork(string filePath, RazorFileChangeKind kind)
17+
{
18+
if (kind is not (RazorFileChangeKind.Added or RazorFileChangeKind.Removed))
19+
{
20+
throw new ArgumentException("Only adds and removes are allowed", nameof(kind));
21+
}
22+
23+
instance._workQueue.AddWork((filePath, kind));
24+
}
25+
26+
public void AddWork(params (string filePath, RazorFileChangeKind kind)[] items)
27+
{
28+
foreach (var (filePath, kind) in items)
29+
{
30+
if (kind is not (RazorFileChangeKind.Added or RazorFileChangeKind.Removed))
31+
{
32+
throw new ArgumentException("Only adds and removes are allowed", nameof(items));
33+
}
34+
}
35+
36+
instance._workQueue.AddWork(items);
37+
}
38+
39+
public Task WaitUntilCurrentBatchCompletesAsync()
40+
{
41+
return instance._workQueue.WaitUntilCurrentBatchCompletesAsync();
42+
}
43+
}
44+
}

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

Lines changed: 100 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -10,47 +10,42 @@
1010
using System.Threading;
1111
using System.Threading.Tasks;
1212
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
13+
using Microsoft.AspNetCore.Razor.PooledObjects;
1314
using Microsoft.AspNetCore.Razor.Utilities;
1415
using Microsoft.CodeAnalysis.Razor;
16+
using Microsoft.CodeAnalysis.Razor.Utilities;
1517
using Microsoft.VisualStudio.Threading;
1618

1719
namespace Microsoft.AspNetCore.Razor.LanguageServer;
1820

19-
internal class RazorFileChangeDetector : IFileChangeDetector, IDisposable
21+
internal partial class RazorFileChangeDetector : IFileChangeDetector, IDisposable
2022
{
2123
private static readonly TimeSpan s_delay = TimeSpan.FromSeconds(1);
2224
private static readonly ImmutableArray<string> s_razorFileExtensions = [".razor", ".cshtml"];
2325
private static readonly string[] s_ignoredDirectories = ["node_modules"];
2426

25-
// Internal for testing
26-
internal readonly Dictionary<string, DelayedFileChangeNotification> PendingNotifications;
27-
28-
private readonly ProjectSnapshotManagerDispatcher _dispatcher;
2927
private readonly ImmutableArray<IRazorFileChangeListener> _listeners;
30-
private readonly List<FileSystemWatcher> _watchers;
31-
private readonly object _pendingNotificationsLock = new();
3228

3329
private readonly CancellationTokenSource _disposeTokenSource;
34-
private readonly TimeSpan _delay;
30+
private readonly AsyncBatchingWorkQueue<(string, RazorFileChangeKind)> _workQueue;
31+
private readonly Dictionary<string, (RazorFileChangeKind kind, int index)> _filePathToChangeMap;
32+
private readonly HashSet<int> _indicesToSkip;
33+
private readonly List<FileSystemWatcher> _watchers;
3534

36-
public RazorFileChangeDetector(
37-
ProjectSnapshotManagerDispatcher dispatcher,
38-
IEnumerable<IRazorFileChangeListener> listeners)
39-
: this(dispatcher, listeners, s_delay)
35+
public RazorFileChangeDetector(IEnumerable<IRazorFileChangeListener> listeners)
36+
: this(listeners, s_delay)
4037
{
4138
}
4239

43-
protected RazorFileChangeDetector(
44-
ProjectSnapshotManagerDispatcher dispatcher,
45-
IEnumerable<IRazorFileChangeListener> listeners,
46-
TimeSpan delay)
40+
protected RazorFileChangeDetector(IEnumerable<IRazorFileChangeListener> listeners, TimeSpan delay)
4741
{
48-
_dispatcher = dispatcher;
4942
_listeners = listeners.ToImmutableArray();
50-
_watchers = new List<FileSystemWatcher>(s_razorFileExtensions.Length);
51-
PendingNotifications = new Dictionary<string, DelayedFileChangeNotification>(FilePathComparer.Instance);
52-
_delay = delay;
43+
5344
_disposeTokenSource = new();
45+
_workQueue = new AsyncBatchingWorkQueue<(string, RazorFileChangeKind)>(delay, ProcessBatchAsync, _disposeTokenSource.Token);
46+
_filePathToChangeMap = new(FilePathComparer.Instance);
47+
_indicesToSkip = [];
48+
_watchers = new List<FileSystemWatcher>(s_razorFileExtensions.Length);
5449
}
5550

5651
public void Dispose()
@@ -59,11 +54,81 @@ public void Dispose()
5954
_disposeTokenSource.Dispose();
6055
}
6156

62-
// Used in tests to ensure we can control when delayed notification work starts.
63-
internal ManualResetEventSlim? BlockNotificationWorkStart { get; set; }
57+
private async ValueTask ProcessBatchAsync(ImmutableArray<(string, RazorFileChangeKind)> items, CancellationToken token)
58+
{
59+
// Clear out our helper collections.
60+
_filePathToChangeMap.Clear();
61+
_indicesToSkip.Clear();
62+
63+
// First, collect all of the file paths and note the indices add/remove change pairs for the same file path.
64+
using var potentialItems = new PooledArrayBuilder<string>(capacity: items.Length);
65+
66+
var index = 0;
67+
68+
foreach (var (filePath, kind) in items)
69+
{
70+
if (token.IsCancellationRequested)
71+
{
72+
return;
73+
}
74+
75+
if (_filePathToChangeMap.TryGetValue(filePath, out var value))
76+
{
77+
// We've already seen this file path, so we should skip it later.
78+
_indicesToSkip.Add(index);
79+
80+
var (existingKind, existingIndex) = value;
6481

65-
// Used in tests to ensure we can understand when notification work noops.
66-
internal ManualResetEventSlim? NotifyNotificationNoop { get; set; }
82+
// We only ever get added or removed. So, if we've already received an add and are getting
83+
// a remove, there's no need to send the notification. Likewise, if we've received a remove
84+
// and are getting an add, we can just elide this notification altogether.
85+
if (kind != existingKind)
86+
{
87+
_filePathToChangeMap.Remove(filePath);
88+
_indicesToSkip.Add(existingIndex);
89+
}
90+
else
91+
{
92+
Debug.Fail($"Unexpected {kind} event because our prior tracked state was the same.");
93+
}
94+
}
95+
else
96+
{
97+
_filePathToChangeMap.Add(filePath, (kind, index));
98+
}
99+
100+
potentialItems.Add(filePath);
101+
index++;
102+
}
103+
104+
// Now, loop through all of the file paths we collected and notify listeners of changes,
105+
// taking care of to skip any indices that we noted earlier.
106+
for (var i = 0; i < potentialItems.Count; i++)
107+
{
108+
if (token.IsCancellationRequested)
109+
{
110+
return;
111+
}
112+
113+
if (_indicesToSkip.Contains(i))
114+
{
115+
continue;
116+
}
117+
118+
var filePath = potentialItems[i];
119+
120+
if (!_filePathToChangeMap.TryGetValue(filePath, out var value))
121+
{
122+
continue;
123+
}
124+
125+
// We only send notifications for the changes that we kept.
126+
foreach (var listener in _listeners)
127+
{
128+
await listener.RazorFileChangedAsync(filePath, value.kind, token).ConfigureAwait(false);
129+
}
130+
}
131+
}
67132

68133
public async Task StartAsync(string workspaceDirectory, CancellationToken cancellationToken)
69134
{
@@ -100,22 +165,22 @@ public async Task StartAsync(string workspaceDirectory, CancellationToken cancel
100165
IncludeSubdirectories = true,
101166
};
102167

103-
watcher.Created += (sender, args) => FileSystemWatcher_RazorFileEvent_Background(args.FullPath, RazorFileChangeKind.Added);
104-
watcher.Deleted += (sender, args) => FileSystemWatcher_RazorFileEvent_Background(args.FullPath, RazorFileChangeKind.Removed);
168+
watcher.Created += (sender, args) => _workQueue.AddWork((args.FullPath, RazorFileChangeKind.Added));
169+
watcher.Deleted += (sender, args) => _workQueue.AddWork((args.FullPath, RazorFileChangeKind.Removed));
105170
watcher.Renamed += (sender, args) =>
106171
{
107172
// Translate file renames into remove->add
108173

109174
if (args.OldFullPath.EndsWith(extension, FilePathComparison.Instance))
110175
{
111176
// Renaming from Razor file to something else.
112-
FileSystemWatcher_RazorFileEvent_Background(args.OldFullPath, RazorFileChangeKind.Removed);
177+
_workQueue.AddWork((args.OldFullPath, RazorFileChangeKind.Removed));
113178
}
114179

115180
if (args.FullPath.EndsWith(extension, FilePathComparison.Instance))
116181
{
117182
// Renaming to a Razor file.
118-
FileSystemWatcher_RazorFileEvent_Background(args.FullPath, RazorFileChangeKind.Added);
183+
_workQueue.AddWork((args.FullPath, RazorFileChangeKind.Added));
119184
}
120185
};
121186

@@ -129,9 +194,9 @@ public void Stop()
129194
{
130195
// We're relying on callers to synchronize start/stops so we don't need to ensure one happens before the other.
131196

132-
for (var i = 0; i < _watchers.Count; i++)
197+
foreach (var watcher in _watchers)
133198
{
134-
_watchers[i].Dispose();
199+
watcher.Dispose();
135200
}
136201

137202
_watchers.Clear();
@@ -143,122 +208,16 @@ protected virtual void OnInitializationFinished()
143208
}
144209

145210
// Protected virtual for testing
146-
protected virtual IReadOnlyList<string> GetExistingRazorFiles(string workspaceDirectory)
211+
protected virtual ImmutableArray<string> GetExistingRazorFiles(string workspaceDirectory)
147212
{
148-
var existingRazorFiles = Enumerable.Empty<string>();
213+
using var result = new PooledArrayBuilder<string>();
214+
149215
foreach (var extension in s_razorFileExtensions)
150216
{
151217
var existingFiles = DirectoryHelper.GetFilteredFiles(workspaceDirectory, "*" + extension, s_ignoredDirectories);
152-
existingRazorFiles = existingRazorFiles.Concat(existingFiles);
218+
result.AddRange(existingFiles);
153219
}
154220

155-
return existingRazorFiles.ToArray();
156-
}
157-
158-
// Internal for testing
159-
internal void FileSystemWatcher_RazorFileEvent_Background(string physicalFilePath, RazorFileChangeKind kind)
160-
{
161-
lock (_pendingNotificationsLock)
162-
{
163-
if (!PendingNotifications.TryGetValue(physicalFilePath, out var currentNotification))
164-
{
165-
currentNotification = new DelayedFileChangeNotification();
166-
PendingNotifications[physicalFilePath] = currentNotification;
167-
}
168-
169-
if (currentNotification.ChangeKind != null)
170-
{
171-
// We've already has a file change event for this file. Chances are we need to normalize the result.
172-
173-
Debug.Assert(currentNotification.ChangeKind == RazorFileChangeKind.Added || currentNotification.ChangeKind == RazorFileChangeKind.Removed);
174-
175-
if (currentNotification.ChangeKind != kind)
176-
{
177-
// Previous was added and current is removed OR previous was removed and current is added. Either way there's no
178-
// actual change to notify, null it out.
179-
currentNotification.ChangeKind = null;
180-
}
181-
else
182-
{
183-
Debug.Fail($"Unexpected {kind} event because our prior tracked state was the same.");
184-
}
185-
}
186-
else
187-
{
188-
currentNotification.ChangeKind = kind;
189-
}
190-
191-
if (currentNotification.NotifyTask is null)
192-
{
193-
// The notify task is only ever null when it's the first time we're being notified about a change to the corresponding file.
194-
currentNotification.NotifyTask = NotifyAfterDelayAsync(physicalFilePath);
195-
}
196-
}
197-
}
198-
199-
private async Task NotifyAfterDelayAsync(string physicalFilePath)
200-
{
201-
await Task.Delay(_delay).ConfigureAwait(false);
202-
203-
OnStartingDelayedNotificationWork();
204-
205-
await _dispatcher.RunAsync(
206-
() => NotifyAfterDelay_ProjectSnapshotManagerDispatcher(physicalFilePath),
207-
_disposeTokenSource.Token).ConfigureAwait(false);
208-
}
209-
210-
private void NotifyAfterDelay_ProjectSnapshotManagerDispatcher(string physicalFilePath)
211-
{
212-
lock (_pendingNotificationsLock)
213-
{
214-
var result = PendingNotifications.TryGetValue(physicalFilePath, out var notification);
215-
Debug.Assert(result, "We should always have an associated notification after delaying an update.");
216-
217-
Assumes.NotNull(notification);
218-
219-
PendingNotifications.Remove(physicalFilePath);
220-
221-
if (notification.ChangeKind is null)
222-
{
223-
// The file to be notified has been brought back to its original state.
224-
// Aka Add -> Remove is equivalent to the file never having been added.
225-
226-
OnNoopingNotificationWork();
227-
228-
return;
229-
}
230-
231-
var kind = notification.ChangeKind.Value;
232-
233-
foreach (var listener in _listeners)
234-
{
235-
listener.RazorFileChangedAsync(physicalFilePath, kind, _disposeTokenSource.Token).Forget();
236-
}
237-
}
238-
}
239-
240-
private void OnStartingDelayedNotificationWork()
241-
{
242-
if (BlockNotificationWorkStart != null)
243-
{
244-
BlockNotificationWorkStart.Wait();
245-
BlockNotificationWorkStart.Reset();
246-
}
247-
}
248-
249-
private void OnNoopingNotificationWork()
250-
{
251-
if (NotifyNotificationNoop != null)
252-
{
253-
NotifyNotificationNoop.Set();
254-
}
255-
}
256-
257-
// Internal for testing
258-
internal class DelayedFileChangeNotification
259-
{
260-
public Task? NotifyTask { get; set; }
261-
262-
public RazorFileChangeKind? ChangeKind { get; set; }
221+
return result.DrainToImmutable();
263222
}
264223
}

0 commit comments

Comments
 (0)