File: ProjectSystem\FileChangeTracker.cs
Web Access
Project: src\src\VisualStudio\Core\Def\Microsoft.VisualStudio.LanguageServices_zthhlzqo_wpftmp.csproj (Microsoft.VisualStudio.LanguageServices)
// 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;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ErrorReporting;
using IVsAsyncFileChangeEx2 = Microsoft.VisualStudio.Shell.IVsAsyncFileChangeEx2;
using Microsoft.VisualStudio.Shell.Interop;
using Roslyn.Utilities;
 
namespace Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem;
 
internal sealed class FileChangeTracker : IVsFreeThreadedFileChangeEvents2, IDisposable
{
    internal const _VSFILECHANGEFLAGS DefaultFileChangeFlags = _VSFILECHANGEFLAGS.VSFILECHG_Time | _VSFILECHANGEFLAGS.VSFILECHG_Add | _VSFILECHANGEFLAGS.VSFILECHG_Del | _VSFILECHANGEFLAGS.VSFILECHG_Size;
 
    private static readonly AsyncLazy<uint?> s_none = AsyncLazy.Create(value: (uint?)null);
 
    private readonly IVsFileChangeEx _fileChangeService;
    private readonly _VSFILECHANGEFLAGS _fileChangeFlags;
    private bool _disposed;
 
    /// <summary>
    /// The cookie received from the IVsFileChangeEx interface that is watching for changes to
    /// this file. This field may never be null, but might be a Lazy that has a value of null if
    /// we either failed to subscribe over never have tried to subscribe.
    /// </summary>
    private AsyncLazy<uint?> _fileChangeCookie;
 
    public event EventHandler UpdatedOnDisk;
 
    /// <summary>
    /// Operations on <see cref="IVsFileChangeEx"/> synchronize on a single lock within that service, so there's no point
    /// in us trying to have multiple threads all trying to use it at the same time. When we queue a new background thread operation
    /// we'll just do a continuation after the previous one. Any callers of <see cref="EnsureSubscription"/> will bypass that queue
    /// and ensure it happens quickly.
    /// </summary>
    private static Task s_lastBackgroundTask = Task.CompletedTask;
 
    /// <summary>
    /// The object to use as a monitor guarding <see cref="s_lastBackgroundTask"/>. This lock is not strictly necessary, since we don't need
    /// to ensure the background tasks happen entirely sequentially -- if we just removed the lock, and two subscriptions happened, we end up with
    /// a 'branching' set of continuations, but that's fine since we're generally not running things in parallel. But it's easy to write,
    /// and easy to delete if this lock has contention itself. Given we tend to call <see cref="StartFileChangeListeningAsync"/> on the UI
    /// thread, I don't expect to see contention.
    /// </summary>
    private static readonly object s_lastBackgroundTaskGate = new();
 
    public FileChangeTracker(IVsFileChangeEx fileChangeService, string filePath, _VSFILECHANGEFLAGS fileChangeFlags = DefaultFileChangeFlags)
    {
        _fileChangeService = fileChangeService;
        FilePath = filePath;
        _fileChangeFlags = fileChangeFlags;
        _fileChangeCookie = s_none;
    }
 
    ~FileChangeTracker()
    {
        if (!Environment.HasShutdownStarted)
        {
            this.AssertUnsubscription();
        }
    }
 
    public string FilePath { get; }
 
    /// <summary>
    /// Returns true if a previous call to <see cref="StartFileChangeListeningAsync"/> has completed.
    /// </summary>
    public bool PreviousCallToStartFileChangeHasAsynchronouslyCompleted
    {
        get
        {
            var cookie = _fileChangeCookie;
            return cookie != s_none && cookie.TryGetValue(out _);
        }
    }
 
    public void AssertUnsubscription()
    {
        // We must have been disposed properly.
        Contract.ThrowIfTrue(_fileChangeCookie != s_none);
    }
 
    public void EnsureSubscription()
    {
        // make sure we have file notification subscribed
        _ = _fileChangeCookie.GetValue(CancellationToken.None);
    }
 
    public Task StartFileChangeListeningAsync()
    {
        if (_disposed)
        {
            throw new ObjectDisposedException(nameof(FileChangeTracker));
        }
 
        Contract.ThrowIfTrue(_fileChangeCookie != s_none);
 
        _fileChangeCookie = AsyncLazy.Create(
            static async (self, cancellationToken) =>
            {
                try
                {
                    // TODO: Should we pass in cancellationToken here instead of CancellationToken.None?
                    uint? result = await ((IVsAsyncFileChangeEx2)self._fileChangeService).AdviseFileChangeAsync(self.FilePath, self._fileChangeFlags, self, CancellationToken.None).ConfigureAwait(false);
                    return result;
                }
                catch (Exception e) when (ReportException(e))
                {
                    return null;
                }
            },
            static (self, cancellationToken) =>
            {
                try
                {
                    Marshal.ThrowExceptionForHR(
                        self._fileChangeService.AdviseFileChange(self.FilePath, (uint)self._fileChangeFlags, self, out var newCookie));
                    return newCookie;
                }
                catch (Exception e) when (ReportException(e))
                {
                    return null;
                }
            },
            arg: this);
 
        lock (s_lastBackgroundTaskGate)
        {
            s_lastBackgroundTask = s_lastBackgroundTask.ContinueWith(_ => _fileChangeCookie.GetValueAsync(CancellationToken.None), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default).Unwrap();
            return s_lastBackgroundTask;
        }
    }
 
    private static bool ReportException(Exception e)
    {
        // If we got a PathTooLongException there's really nothing we can do about it; we will fail to read the file later which is fine
        if (e is not PathTooLongException)
        {
            return FatalError.ReportAndCatch(e);
        }
 
        // We'll always capture all exceptions regardless. If we don't, then the exception is captured by our lazy and will be potentially rethrown from
        // StopFileChangeListening or Dispose which causes all sorts of downstream problems.
        return true;
    }
 
    private void StopFileChangeListening()
    {
        if (_disposed)
        {
            throw new ObjectDisposedException(nameof(FileChangeTracker));
        }
 
        // there is a slight chance that we haven't subscribed to the service yet so we subscribe and unsubscribe
        // both here unnecessarily. but I believe that probably is a theoretical problem and never happen in real life.
        // and even if that happens, it will be just a perf hit
        if (_fileChangeCookie == s_none)
        {
            return;
        }
 
        var fileChangeCookie = _fileChangeCookie.GetValue(CancellationToken.None);
        _fileChangeCookie = s_none;
 
        // We may have tried to subscribe but failed, so have to check a second time
        if (fileChangeCookie.HasValue)
        {
            try
            {
                Marshal.ThrowExceptionForHR(
                    _fileChangeService.UnadviseFileChange(fileChangeCookie.Value));
            }
            catch (Exception e) when (ReportException(e))
            {
            }
        }
    }
 
    public void Dispose()
    {
        this.StopFileChangeListening();
 
        _disposed = true;
 
        GC.SuppressFinalize(this);
    }
 
    int IVsFileChangeEvents.DirectoryChanged(string directory)
        => throw new Exception("We only watch files; we should never be seeing directory changes!");
 
    int IVsFileChangeEvents.FilesChanged(uint changeCount, string[] files, uint[] changes)
    {
        UpdatedOnDisk?.Invoke(this, EventArgs.Empty);
 
        return VSConstants.S_OK;
    }
 
    int IVsFreeThreadedFileChangeEvents2.FilesChanged(uint cChanges, string[] rgpszFile, uint[] rggrfChange)
    {
        UpdatedOnDisk?.Invoke(this, EventArgs.Empty);
 
        return VSConstants.S_OK;
    }
 
    int IVsFreeThreadedFileChangeEvents2.DirectoryChanged(string pszDirectory)
        => throw new Exception("We only watch files; we should never be seeing directory changes!");
 
    int IVsFreeThreadedFileChangeEvents2.DirectoryChangedEx(string pszDirectory, string pszFile)
        => throw new Exception("We only watch files; we should never be seeing directory changes!");
 
    int IVsFreeThreadedFileChangeEvents2.DirectoryChangedEx2(string pszDirectory, uint cChanges, string[] rgpszFile, uint[] rggrfChange)
        => throw new Exception("We only watch files; we should never be seeing directory changes!");
 
    int IVsFreeThreadedFileChangeEvents.FilesChanged(uint cChanges, string[] rgpszFile, uint[] rggrfChange)
    {
        UpdatedOnDisk?.Invoke(this, EventArgs.Empty);
 
        return VSConstants.S_OK;
    }
 
    int IVsFreeThreadedFileChangeEvents.DirectoryChanged(string pszDirectory)
        => throw new Exception("We only watch files; we should never be seeing directory changes!");
 
    int IVsFreeThreadedFileChangeEvents.DirectoryChangedEx(string pszDirectory, string pszFile)
        => throw new Exception("We only watch files; we should never be seeing directory changes!");
}