|
15 | 15 | using Microsoft.CodeAnalysis.PooledObjects;
|
16 | 16 | using Microsoft.CodeAnalysis.Shared.Extensions;
|
17 | 17 | using Microsoft.CodeAnalysis.Text;
|
| 18 | +using Microsoft.CodeAnalysis.Threading; |
18 | 19 | using Roslyn.Utilities;
|
19 | 20 |
|
20 | 21 | namespace Microsoft.CodeAnalysis.Workspaces.ProjectSystem;
|
@@ -69,6 +70,18 @@ private sealed class BatchingDocumentCollection
|
69 | 70 | private readonly Func<Solution, DocumentId, TextLoader, Solution> _documentTextLoaderChangedAction;
|
70 | 71 | private readonly WorkspaceChangeKind _documentChangedWorkspaceKind;
|
71 | 72 |
|
| 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 | + |
72 | 85 | public BatchingDocumentCollection(ProjectSystemProject project,
|
73 | 86 | Func<Solution, DocumentId, bool> documentAlreadyInWorkspace,
|
74 | 87 | Action<Workspace, DocumentInfo> documentAddAction,
|
@@ -439,55 +452,66 @@ await _project._projectSystemProjectFactory.ApplyBatchChangeToWorkspaceAsync((so
|
439 | 452 | /// <summary>
|
440 | 453 | /// Process file content changes
|
441 | 454 | /// </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> |
444 | 457 | public void ProcessDynamicFileChange(string projectSystemFilePath, string workspaceFilePath)
|
445 | 458 | {
|
446 |
| - using (_project._gate.DisposableWait()) |
| 459 | + InterlockedOperations.Initialize(ref _dynamicFilesToRefresh, () => |
447 | 460 | {
|
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; |
453 | 478 |
|
454 |
| - if (_documentPathsToDocumentIds.TryGetValue(workspaceFilePath, out var documentId)) |
| 479 | + using (await _project._gate.DisposableWaitAsync(cancellationToken).ConfigureAwait(false)) |
455 | 480 | {
|
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) |
463 | 483 | return;
|
464 |
| - } |
465 | 484 |
|
466 |
| - Contract.ThrowIfFalse(_documentIdToDynamicFileInfoProvider.TryGetValue(documentId, out var fileInfoProvider)); |
| 485 | + if (!_documentPathsToDocumentIds.TryGetValue(workspaceFilePath, out documentId)) |
| 486 | + return; |
467 | 487 |
|
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 => |
469 | 498 | {
|
470 | 499 | if (w.IsDocumentOpen(documentId))
|
471 | 500 | {
|
472 | 501 | return;
|
473 | 502 | }
|
474 | 503 |
|
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); |
482 | 507 |
|
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; |
485 | 510 |
|
486 |
| - var documentInfo = new DocumentInfo(attributes, fileInfo.TextLoader, fileInfo.DocumentServiceProvider); |
| 511 | + var documentInfo = new DocumentInfo(documentToReload.State.Attributes, fileInfo.TextLoader, fileInfo.DocumentServiceProvider); |
487 | 512 |
|
488 | 513 | w.OnDocumentReloaded(documentInfo);
|
489 |
| - }); |
490 |
| - } |
| 514 | + }, cancellationToken).ConfigureAwait(false); |
491 | 515 | }
|
492 | 516 | }
|
493 | 517 |
|
|
0 commit comments