File: Workspace\BackgroundParser.cs
Web Access
Project: ..\..\..\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
#nullable disable
 
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.SolutionCrawler;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.Host
{
    /// <summary>
    /// when users type, we chain all those changes as incremental parsing requests 
    /// but doesn't actually realize those changes. it is saved as a pending request. 
    /// so if nobody asks for final parse tree, those chain can keep grow. 
    /// we do this since Roslyn is lazy at the core (don't do work if nobody asks for it)
    /// 
    /// but certain host such as VS, we have this (BackgroundParser) which preemptively 
    /// trying to realize such trees for open/active files expecting users will use them soonish.
    /// </summary>
    internal sealed class BackgroundParser
    {
        private readonly Workspace _workspace;
        private readonly TaskQueue _taskQueue;
        private readonly IDocumentTrackingService _documentTrackingService;
 
        private readonly ReaderWriterLockSlim _stateLock = new(LockRecursionPolicy.NoRecursion);
 
        private readonly object _parseGate = new();
        private ImmutableDictionary<DocumentId, CancellationTokenSource> _workMap = ImmutableDictionary.Create<DocumentId, CancellationTokenSource>();
 
        public bool IsStarted { get; private set; }
 
        public BackgroundParser(Workspace workspace)
        {
            _workspace = workspace;
 
            var listenerProvider = workspace.Services.GetRequiredService<IWorkspaceAsynchronousOperationListenerProvider>();
            _taskQueue = new TaskQueue(listenerProvider.GetListener(), TaskScheduler.Default);
 
            _documentTrackingService = workspace.Services.GetRequiredService<IDocumentTrackingService>();
            _documentTrackingService.ActiveDocumentChanged += OnActiveDocumentChanged;
 
            _workspace.WorkspaceChanged += OnWorkspaceChanged;
 
            workspace.DocumentOpened += OnDocumentOpened;
            workspace.DocumentClosed += OnDocumentClosed;
        }
 
        private void OnActiveDocumentChanged(object sender, DocumentId activeDocumentId)
            => Parse(_workspace.CurrentSolution.GetDocument(activeDocumentId));
 
        private void OnDocumentOpened(object sender, DocumentEventArgs args)
            => Parse(args.Document);
 
        private void OnDocumentClosed(object sender, DocumentEventArgs args)
            => CancelParse(args.Document.Id);
 
        private void OnWorkspaceChanged(object sender, WorkspaceChangeEventArgs args)
        {
            switch (args.Kind)
            {
                case WorkspaceChangeKind.SolutionCleared:
                case WorkspaceChangeKind.SolutionRemoved:
                case WorkspaceChangeKind.SolutionAdded:
                    CancelAllParses();
                    break;
 
                case WorkspaceChangeKind.DocumentRemoved:
                    CancelParse(args.DocumentId);
                    break;
 
                case WorkspaceChangeKind.DocumentChanged:
                    ParseIfOpen(args.NewSolution.GetDocument(args.DocumentId));
                    break;
 
                case WorkspaceChangeKind.ProjectChanged:
 
                    var oldProject = args.OldSolution.GetProject(args.ProjectId);
                    var newProject = args.NewSolution.GetProject(args.ProjectId);
 
                    // Perf optimization: don't rescan the new project if parse options didn't change. When looking
                    // at the perf of changing configurations that resulted in many reference additions/removals,
                    // this consumed around 2%-3% of the trace after some other optimizations I did. Most of that
                    // was actually walking the documents list since this was causing all the Documents to be realized.
                    // Since this is on the UI thread, it's best just to not do the work if we don't need it.
                    if (oldProject.SupportsCompilation &&
                        !object.Equals(oldProject.ParseOptions, newProject.ParseOptions))
                    {
                        foreach (var doc in newProject.Documents)
                        {
                            ParseIfOpen(doc);
                        }
                    }
 
                    break;
            }
        }
 
        public void Start()
        {
            using (_stateLock.DisposableRead())
            {
                if (!IsStarted)
                {
                    IsStarted = true;
                }
            }
        }
 
        public void Stop()
        {
            using (_stateLock.DisposableWrite())
            {
                if (IsStarted)
                {
                    CancelAllParses_NoLock();
                    IsStarted = false;
                }
            }
        }
 
        public void CancelAllParses()
        {
            using (_stateLock.DisposableWrite())
            {
                CancelAllParses_NoLock();
            }
        }
 
        private void CancelAllParses_NoLock()
        {
            _stateLock.AssertCanWrite();
 
            foreach (var tuple in _workMap)
            {
                tuple.Value.Cancel();
            }
 
            _workMap = ImmutableDictionary.Create<DocumentId, CancellationTokenSource>();
        }
 
        public void CancelParse(DocumentId documentId)
        {
            if (documentId != null)
            {
                using (_stateLock.DisposableWrite())
                {
                    if (_workMap.TryGetValue(documentId, out var cancellationTokenSource))
                    {
                        cancellationTokenSource.Cancel();
                        _workMap = _workMap.Remove(documentId);
                    }
                }
            }
        }
 
        public void Parse(Document document)
        {
            if (document != null)
            {
                lock (_parseGate)
                {
                    CancelParse(document.Id);
 
                    if (IsStarted)
                    {
                        _ = ParseDocumentAsync(document);
                    }
                }
            }
        }
 
        private void ParseIfOpen(Document document)
        {
            if (document != null && document.IsOpen())
            {
                Parse(document);
            }
        }
 
        private Task ParseDocumentAsync(Document document)
        {
            var cancellationTokenSource = new CancellationTokenSource();
 
            using (_stateLock.DisposableWrite())
            {
                _workMap = _workMap.Add(document.Id, cancellationTokenSource);
            }
 
            var cancellationToken = cancellationTokenSource.Token;
 
            // We end up creating a chain of parsing tasks that each attempt to produce 
            // the appropriate syntax tree for any given document. Once we start work to create 
            // the syntax tree for a given document, we don't want to stop. 
            // Otherwise we can end up in the unfortunate scenario where we keep cancelling work, 
            // and then having the next task re-do the work we were just in the middle of. 
            // By not cancelling, we can reuse the useful results of previous tasks when performing later steps in the chain.
            //
            // we still cancel whole task if the task didn't start yet. we just don't cancel if task is started but not finished yet.
            return _taskQueue.ScheduleTask(
                "BackgroundParser.ParseDocumentAsync",
                async () =>
                {
                    try
                    {
                        await document.GetSyntaxTreeAsync(CancellationToken.None).ConfigureAwait(false);
                    }
                    finally
                    {
                        // Always ensure that we mark this work as done from the workmap.
                        using (_stateLock.DisposableWrite())
                        {
                            // Check that we are still the active parse in the workmap before we remove it.
                            // Concievably if this continuation got delayed and another parse was put in, we might
                            // end up removing the tracking for another in-flight task.
                            if (_workMap.TryGetValue(document.Id, out var sourceInMap) && sourceInMap == cancellationTokenSource)
                            {
                                _workMap = _workMap.Remove(document.Id);
                            }
                        }
                    }
                },
                cancellationToken);
        }
    }
}