// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Threading; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Legacy; using Microsoft.VisualStudio.Text; namespace Microsoft.VisualStudio.Editor.Razor { internal class BackgroundParser : IDisposable { private MainThreadState _main; private BackgroundThread _bg; public BackgroundParser(RazorProjectEngine projectEngine, string filePath, string projectDirectory) { _main = new MainThreadState(filePath); _bg = new BackgroundThread(_main, projectEngine, filePath, projectDirectory); _main.ResultsReady += (sender, args) => OnResultsReady(args); } /// /// Fired on the main thread. /// public event EventHandler ResultsReady; public bool IsIdle { get { return _main.IsIdle; } } public void Start() { _bg.Start(); } public void Cancel() { _main.Cancel(); } public void QueueChange(SourceChange change, ITextSnapshot snapshot) { var edit = new Edit(change, snapshot); _main.QueueChange(edit); } public void Dispose() { _main.Cancel(); } public IDisposable SynchronizeMainThreadState() { return _main.Lock(); } protected virtual void OnResultsReady(DocumentStructureChangedEventArgs args) { using (SynchronizeMainThreadState()) { ResultsReady?.Invoke(this, args); } } private abstract class ThreadStateBase { #if DEBUG private int _id = -1; #endif protected ThreadStateBase() { } [Conditional("DEBUG")] protected void SetThreadId(int id) { #if DEBUG _id = id; #endif } [Conditional("DEBUG")] protected void EnsureOnThread() { #if DEBUG Debug.Assert(_id != -1, "SetThreadId was never called!"); Debug.Assert(Thread.CurrentThread.ManagedThreadId == _id, "Called from an unexpected thread!"); #endif } [Conditional("DEBUG")] protected void EnsureNotOnThread() { #if DEBUG Debug.Assert(_id != -1, "SetThreadId was never called!"); Debug.Assert(Thread.CurrentThread.ManagedThreadId != _id, "Called from an unexpected thread!"); #endif } } private class MainThreadState : ThreadStateBase, IDisposable { private readonly CancellationTokenSource _cancelSource = new CancellationTokenSource(); private readonly ManualResetEventSlim _hasParcel = new ManualResetEventSlim(false); private CancellationTokenSource _currentParcelCancelSource; private string _fileName; private readonly object _stateLock = new object(); private IList _changes = new List(); public MainThreadState(string fileName) { _fileName = fileName; SetThreadId(Thread.CurrentThread.ManagedThreadId); } public event EventHandler ResultsReady; public CancellationToken CancelToken { get { return _cancelSource.Token; } } public bool IsIdle { get { lock (_stateLock) { return _currentParcelCancelSource == null; } } } public void Cancel() { EnsureOnThread(); _cancelSource.Cancel(); } public IDisposable Lock() { Monitor.Enter(_stateLock); return new DisposableAction(() => Monitor.Exit(_stateLock)); } public void QueueChange(Edit edit) { // Any thread can queue a change. lock (_stateLock) { // CurrentParcel token source is not null ==> There's a parse underway if (_currentParcelCancelSource != null) { _currentParcelCancelSource.Cancel(); } _changes.Add(edit); _hasParcel.Set(); } } public WorkParcel GetParcel() { EnsureNotOnThread(); // Only the background thread can get a parcel _hasParcel.Wait(_cancelSource.Token); _hasParcel.Reset(); lock (_stateLock) { // Create a cancellation source for this parcel _currentParcelCancelSource = new CancellationTokenSource(); var changes = _changes; _changes = new List(); return new WorkParcel(changes, _currentParcelCancelSource.Token); } } public void ReturnParcel(DocumentStructureChangedEventArgs args) { lock (_stateLock) { // Clear the current parcel cancellation source if (_currentParcelCancelSource != null) { _currentParcelCancelSource.Dispose(); _currentParcelCancelSource = null; } // If there are things waiting to be parsed, just don't fire the event because we're already out of date if (_changes.Any()) { return; } } var handler = ResultsReady; if (handler != null) { handler(this, args); } } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (disposing) { if (_currentParcelCancelSource != null) { _currentParcelCancelSource.Dispose(); _currentParcelCancelSource = null; } _cancelSource.Dispose(); _hasParcel.Dispose(); } } } private class BackgroundThread : ThreadStateBase { private readonly string _filePath; private readonly string _relativeFilePath; private readonly string _projectDirectory; private MainThreadState _main; private Thread _backgroundThread; private CancellationToken _shutdownToken; private RazorProjectEngine _projectEngine; private RazorSyntaxTree _currentSyntaxTree; private IList _previouslyDiscarded = new List(); public BackgroundThread(MainThreadState main, RazorProjectEngine projectEngine, string filePath, string projectDirectory) { // Run on MAIN thread! _main = main; _shutdownToken = _main.CancelToken; _projectEngine = projectEngine; _filePath = filePath; _relativeFilePath = GetNormalizedRelativeFilePath(filePath, projectDirectory); _projectDirectory = projectDirectory; _backgroundThread = new Thread(WorkerLoop); SetThreadId(_backgroundThread.ManagedThreadId); } // **** ANY THREAD **** public void Start() { _backgroundThread.Start(); } // **** BACKGROUND THREAD **** private void WorkerLoop() { try { EnsureOnThread(); while (!_shutdownToken.IsCancellationRequested) { // Grab the parcel of work to do var parcel = _main.GetParcel(); if (parcel.Edits.Any()) { try { DocumentStructureChangedEventArgs args = null; using (var linkedCancel = CancellationTokenSource.CreateLinkedTokenSource(_shutdownToken, parcel.CancelToken)) { if (!linkedCancel.IsCancellationRequested) { // Collect ALL changes List allEdits; if (_previouslyDiscarded != null) { allEdits = Enumerable.Concat(_previouslyDiscarded, parcel.Edits).ToList(); } else { allEdits = parcel.Edits.ToList(); } var finalEdit = allEdits.Last(); var results = ParseChange(finalEdit.Snapshot, linkedCancel.Token); if (results != null && !linkedCancel.IsCancellationRequested) { // Clear discarded changes list _previouslyDiscarded = null; _currentSyntaxTree = results.GetSyntaxTree(); // Build Arguments args = new DocumentStructureChangedEventArgs( finalEdit.Change, finalEdit.Snapshot, results); } else { // Parse completed but we were cancelled in the mean time. Add these to the discarded changes set _previouslyDiscarded = allEdits; } } } if (args != null) { _main.ReturnParcel(args); } } catch (OperationCanceledException) { } } else { Thread.Yield(); } } } catch (OperationCanceledException) { // Do nothing. Just shut down. } finally { // Clean up main thread resources _main.Dispose(); } } private RazorCodeDocument ParseChange(ITextSnapshot snapshot, CancellationToken token) { EnsureOnThread(); var projectItem = new TextSnapshotProjectItem(snapshot, _projectDirectory, _relativeFilePath, _filePath); var codeDocument = _projectEngine.ProcessDesignTime(projectItem); return codeDocument; } private string GetNormalizedRelativeFilePath(string filePath, string projectDirectory) { if (filePath.StartsWith(projectDirectory, StringComparison.OrdinalIgnoreCase)) { filePath = filePath.Substring(projectDirectory.Length); } if (filePath.Length > 1) { filePath = filePath.Replace('\\', '/'); if (filePath[0] != '/') { filePath = "/" + filePath; } } return filePath; } } private class WorkParcel { public WorkParcel(IList changes, CancellationToken cancelToken) { Edits = changes; CancelToken = cancelToken; } public CancellationToken CancelToken { get; } public IList Edits { get; } } private class Edit { public Edit(SourceChange change, ITextSnapshot snapshot) { Change = change; Snapshot = snapshot; } public SourceChange Change { get; } public ITextSnapshot Snapshot { get; set; } } } }