Skip to content

Commit 0b121e1

Browse files
Don't refresh dynamic files under a lock (#78775)
When we were getting the notification of a dynamic file being changed, we were taking the project and workspace locks before fetching the actual content. This is really expensive now that the fetching itself might not be instant. Fundamentally this change is just switching the work to acquire the locks to get the setup info, then releasing the locks and calling the provider to get the info. I'm putting this around a batching queue so we can deduplicate lots of events at once, and also to ensure ordering so if we were to try refreshing the document more than once we don't end up in a case where we have two threads trying to put different versions of the document into the workspace and getting the ordering wrong. Fixes #78734
2 parents 8edf1c0 + efbbe24 commit 0b121e1

File tree

2 files changed

+60
-36
lines changed

2 files changed

+60
-36
lines changed

src/Workspaces/Core/Portable/Workspace/ProjectSystem/ProjectSystemProject.BatchingDocumentCollection.cs

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using Microsoft.CodeAnalysis.PooledObjects;
1616
using Microsoft.CodeAnalysis.Shared.Extensions;
1717
using Microsoft.CodeAnalysis.Text;
18+
using Microsoft.CodeAnalysis.Threading;
1819
using Roslyn.Utilities;
1920

2021
namespace Microsoft.CodeAnalysis.Workspaces.ProjectSystem;
@@ -69,6 +70,18 @@ private sealed class BatchingDocumentCollection
6970
private readonly Func<Solution, DocumentId, TextLoader, Solution> _documentTextLoaderChangedAction;
7071
private readonly WorkspaceChangeKind _documentChangedWorkspaceKind;
7172

73+
/// <summary>
74+
/// An <see cref="AsyncBatchingWorkQueue"/> for processing updates to dynamic files. This is lazily created the first time we see
75+
/// a change to process, since dynamic files are only used in certain Razor scenarios and most projects won't ever have one.
76+
/// </summary>
77+
/// <remarks>
78+
/// This is used for two reasons: first, if we have a flurry of events we want to deduplicate them. But it also ensures ordering -- if we were to get a change to
79+
/// a dynamic file while we're already processing another change, we want to ensure that the first change is processed before the second one. Otherwise we might
80+
/// end up with the updates being applied out of order (since we're not always holding a lock while calling to the dynamic file info provider) and we might end up with
81+
/// an old version stuck in the workspace.
82+
/// </remarks>
83+
private AsyncBatchingWorkQueue<(string projectSystemFilePath, string workspaceFilePath)>? _dynamicFilesToRefresh;
84+
7285
public BatchingDocumentCollection(ProjectSystemProject project,
7386
Func<Solution, DocumentId, bool> documentAlreadyInWorkspace,
7487
Action<Workspace, DocumentInfo> documentAddAction,
@@ -213,7 +226,7 @@ public void AddDynamicFile_NoLock(IDynamicFileInfoProvider fileInfoProvider, Dyn
213226

214227
_documentIdToDynamicFileInfoProvider.Add(documentId, fileInfoProvider);
215228

216-
if (_project._eventSubscriptionTracker.Add(fileInfoProvider))
229+
if (_project._dynamicFileInfoProvidersSubscribedTo.Add(fileInfoProvider))
217230
{
218231
// subscribe to the event when we use this provider the first time
219232
fileInfoProvider.Updated += _project.OnDynamicFileInfoUpdated;
@@ -439,55 +452,66 @@ await _project._projectSystemProjectFactory.ApplyBatchChangeToWorkspaceAsync((so
439452
/// <summary>
440453
/// Process file content changes
441454
/// </summary>
442-
/// <param name="projectSystemFilePath">filepath given from project system</param>
443-
/// <param name="workspaceFilePath">filepath used in workspace. it might be different than projectSystemFilePath</param>
455+
/// <param name="projectSystemFilePath">File path given from project system for the .cshtml file</param>
456+
/// <param name="workspaceFilePath">File path for the equivalent .cs document used in workspace. it might be different than projectSystemFilePath.</param>
444457
public void ProcessDynamicFileChange(string projectSystemFilePath, string workspaceFilePath)
445458
{
446-
using (_project._gate.DisposableWait())
459+
InterlockedOperations.Initialize(ref _dynamicFilesToRefresh, () =>
447460
{
448-
// If our project has already been removed, this is a stale notification, and we can disregard.
449-
if (_project.HasBeenRemoved)
450-
{
451-
return;
452-
}
461+
return new AsyncBatchingWorkQueue<(string, string)>(
462+
TimeSpan.FromMilliseconds(200), // 200 chosen with absolutely no evidence whatsoever
463+
ProcessDynamicFileChangesAsync,
464+
EqualityComparer<(string, string)>.Default, // uses ordinal string comparison which is what we want
465+
_project._projectSystemProjectFactory.WorkspaceListener,
466+
_project._asynchronousFileChangeProcessingCancellationTokenSource.Token);
467+
});
468+
469+
_dynamicFilesToRefresh.AddWork((projectSystemFilePath, workspaceFilePath));
470+
}
471+
472+
private async ValueTask ProcessDynamicFileChangesAsync(ImmutableSegmentedList<(string projectSystemFilePath, string workspaceFilePath)> batch, CancellationToken cancellationToken)
473+
{
474+
foreach (var (projectSystemPath, workspaceFilePath) in batch)
475+
{
476+
DocumentId? documentId;
477+
IDynamicFileInfoProvider? fileInfoProvider;
453478

454-
if (_documentPathsToDocumentIds.TryGetValue(workspaceFilePath, out var documentId))
479+
using (await _project._gate.DisposableWaitAsync(cancellationToken).ConfigureAwait(false))
455480
{
456-
// We create file watching prior to pushing the file to the workspace in batching, so it's
457-
// possible we might see a file change notification early. In this case, toss it out. Since
458-
// all adds/removals of documents for this project happen under our lock, it's safe to do this
459-
// check without taking the main workspace lock. We don't have to check for documents removed in
460-
// the batch, since those have already been removed out of _documentPathsToDocumentIds.
461-
if (_documentsAddedInBatch.Any(d => d.Id == documentId))
462-
{
481+
// If our project has already been removed, this is a stale notification, and we can disregard.
482+
if (_project.HasBeenRemoved)
463483
return;
464-
}
465484

466-
Contract.ThrowIfFalse(_documentIdToDynamicFileInfoProvider.TryGetValue(documentId, out var fileInfoProvider));
485+
if (!_documentPathsToDocumentIds.TryGetValue(workspaceFilePath, out documentId))
486+
return;
467487

468-
_project._projectSystemProjectFactory.ApplyChangeToWorkspace(w =>
488+
if (!_documentIdToDynamicFileInfoProvider.TryGetValue(documentId, out fileInfoProvider))
489+
return;
490+
}
491+
492+
// Now that we've got all our basic data, let's fetch the new document outside the lock, since this could be expensive.
493+
var fileInfo = await fileInfoProvider.GetDynamicFileInfoAsync(
494+
_project.Id, _project._filePath, projectSystemPath, CancellationToken.None).ConfigureAwait(false);
495+
Contract.ThrowIfNull(fileInfo, "We previously received a dynamic file for this path, and we're responding to a change, so we expect to get a new one.");
496+
497+
await _project._projectSystemProjectFactory.ApplyChangeToWorkspaceAsync(w =>
469498
{
470499
if (w.IsDocumentOpen(documentId))
471500
{
472501
return;
473502
}
474503

475-
// we do not expect JTF to be used around this code path. and contract of fileInfoProvider is it being real free-threaded
476-
// meaning it can't use JTF to go back to UI thread.
477-
// so, it is okay for us to call regular ".Result" on a task here.
478-
var fileInfo = fileInfoProvider.GetDynamicFileInfoAsync(
479-
_project.Id, _project._filePath, projectSystemFilePath, CancellationToken.None).WaitAndGetResult_CanCallOnBackground(CancellationToken.None);
480-
481-
Contract.ThrowIfNull(fileInfo, "We previously received a dynamic file for this path, and we're responding to a change, so we expect to get a new one.");
504+
// Right now we're only supporting dynamic files as actual source files, so it's OK to call GetDocument here.
505+
// If the document is longer present, that could mean we unloaded the project, or the dynamic file was removed while we had released the lock.
506+
var documentToReload = w.CurrentSolution.GetDocument(documentId);
482507

483-
// Right now we're only supporting dynamic files as actual source files, so it's OK to call GetDocument here
484-
var attributes = w.CurrentSolution.GetRequiredDocument(documentId).State.Attributes;
508+
if (documentToReload is null)
509+
return;
485510

486-
var documentInfo = new DocumentInfo(attributes, fileInfo.TextLoader, fileInfo.DocumentServiceProvider);
511+
var documentInfo = new DocumentInfo(documentToReload.State.Attributes, fileInfo.TextLoader, fileInfo.DocumentServiceProvider);
487512

488513
w.OnDocumentReloaded(documentInfo);
489-
});
490-
}
514+
}, cancellationToken).ConfigureAwait(false);
491515
}
492516
}
493517

src/Workspaces/Core/Portable/Workspace/ProjectSystem/ProjectSystemProject.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,9 @@ internal sealed partial class ProjectSystemProject
124124
private readonly IFileChangeContext _documentFileChangeContext;
125125

126126
/// <summary>
127-
/// track whether we have been subscribed to <see cref="IDynamicFileInfoProvider.Updated"/> event
127+
/// The set of dynamic file info providers we have already subscribed to.
128128
/// </summary>
129-
private readonly HashSet<IDynamicFileInfoProvider> _eventSubscriptionTracker = [];
129+
private readonly HashSet<IDynamicFileInfoProvider> _dynamicFileInfoProvidersSubscribedTo = [];
130130

131131
/// <summary>
132132
/// Map of the original dynamic file path to the <see cref="DynamicFileInfo.FilePath"/> that was associated with it.
@@ -1417,12 +1417,12 @@ public void RemoveFromWorkspace()
14171417
_asynchronousFileChangeProcessingCancellationTokenSource.Cancel();
14181418

14191419
// clear tracking to external components
1420-
foreach (var provider in _eventSubscriptionTracker)
1420+
foreach (var provider in _dynamicFileInfoProvidersSubscribedTo)
14211421
{
14221422
provider.Updated -= OnDynamicFileInfoUpdated;
14231423
}
14241424

1425-
_eventSubscriptionTracker.Clear();
1425+
_dynamicFileInfoProvidersSubscribedTo.Clear();
14261426
}
14271427

14281428
_documentFileChangeContext.Dispose();

0 commit comments

Comments
 (0)