/
githubmirror
/
roslyn
Обзор
Документация
Войти
/
githubmirror
/
roslyn
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/VisualStudio/Core/Def/ProjectSystem/FileChangeWatcher.cs
520 строк
22 KB
Tomáš Matoušek
Move async utils to Microsoft.CodeAnalysis.Threading source package (#76937)
05 фев 2025, 23:07
Не верифицирован
05 фев 2025, 23:07
dead7da
Код
Авторство
О чём код?
// 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. using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Collections; using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.CodeAnalysis.ProjectSystem; using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Threading; using Microsoft.VisualStudio.Shell.Interop; using Roslyn.Utilities; using IVsAsyncFileChangeEx2 = Microsoft.VisualStudio.Shell.IVsAsyncFileChangeEx2; namespace Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem; /// <summary> /// A service that wraps the Visual Studio file watching APIs to make them more convenient for use. With this, a consumer can create /// an <see cref="IFileChangeContext"/> which lets you add/remove files being watched, and an event is raised when a file is modified. /// </summary> internal sealed class FileChangeWatcher : IFileChangeWatcher { private readonly Task<IVsAsyncFileChangeEx2> _fileChangeService; /// <summary> /// We create a batching queue of operations against the IVsFileChangeEx service for two reasons. First, we are obtaining the service asynchronously, and don't want to /// block on it being available, so anybody who wants to do anything must wait for it. Secondly, the service itself is single-threaded; the entry points /// are asynchronous so we avoid starving the thread pool, but there's still no reason to create a lot more work blocked than needed. Finally, since this /// is all happening async, we generally need to ensure that an operation that happens for an earlier call to this is done before we do later calls. For example, /// if we started a subscription for a file, we need to make sure that's done before we try to unsubscribe from it. /// For performance and correctness reasons, NOTHING should ever do a block on this; figure out how to do your work without a block and add any work to /// the end of the queue. /// </summary> private readonly AsyncBatchingWorkQueue<WatcherOperation> _taskQueue; public FileChangeWatcher( IAsynchronousOperationListenerProvider listenerProvider, Task<IVsAsyncFileChangeEx2> fileChangeService) { _fileChangeService = fileChangeService; // 📝 Empirical testing during high activity (e.g. solution open/close) showed strong batching performance // with delay of 500 ms, batching over 95% of the calls. _taskQueue = new AsyncBatchingWorkQueue<WatcherOperation>( TimeSpan.FromMilliseconds(500), ProcessBatchAsync, listenerProvider.GetListener(FeatureAttribute.Workspace), CancellationToken.None); } private async ValueTask ProcessBatchAsync(ImmutableSegmentedList<WatcherOperation> operations, CancellationToken cancellationToken) { var service = await _fileChangeService.ConfigureAwait(false); for (var startIndex = 0; startIndex < operations.Count; startIndex++) { var combinableEndIndex = FindCombinableRange(operations, startIndex); var combinedOp = WatcherOperation.CombineRange(operations, startIndex, combinableEndIndex); await combinedOp.ApplyAsync(service, cancellationToken).ConfigureAwait(false); startIndex = combinableEndIndex; } return; static int FindCombinableRange(ImmutableSegmentedList<WatcherOperation> operations, int startIndex) { var firstOp = operations[startIndex]; for (var endIndex = startIndex + 1; endIndex < operations.Count; endIndex++) { if (!firstOp.CanCombineWith(operations[endIndex])) return endIndex - 1; } return operations.Count - 1; } } public IFileChangeContext CreateContext(ImmutableArray<WatchedDirectory> watchedDirectories) => new Context(this, watchedDirectories); /// <summary> /// Represents an operation to subscribe or unsubscribe from <see cref="IVsAsyncFileChangeEx2"/> events. The /// values of the fields depends on the <see cref="_kind"/> of the particular instance. /// </summary> private readonly struct WatcherOperation { /// <summary> /// The kind of the watcher operation. The values of individual fields depends on the kind. /// </summary> private readonly Kind _kind; /// <summary> /// The extension filters to apply for <see cref="Kind.WatchDirectory"/>. This value may be /// empty to disable the extension filter. /// </summary> private readonly ImmutableArray<string> _filters; /// <summary> /// The file change flags to apply for <see cref="Kind.WatchFiles"/>. /// </summary> private readonly _VSFILECHANGEFLAGS _fileChangeFlags; /// <summary> /// The instance to receive callback events for <see cref="Kind.WatchDirectory"/> or /// <see cref="Kind.WatchFiles"/>. /// </summary> private readonly IVsFreeThreadedFileChangeEvents2 _sink; /// <summary> /// The collection holding cookies. For <see cref="Kind.WatchDirectory"/>, the operation will add the /// resulting cookie to this collection. For <see cref="Kind.UnwatchDirectories"/>, the operation will /// unsubscribe to all cookies in the collection. /// </summary> /// <remarks> /// ⚠ Do not change this to another collection like <c>ImmutableList<uint></c>. This collection /// references an instance held by the <see cref="Context"/> class, and the values are lazily read when /// <see cref="CombineRange"/> is called. /// </remarks> private readonly List<uint> _cookies; /// <summary> /// A collection of file watcher tokens. The <see cref="Context.RegularWatchedFile.Cookie"/> field is /// assigned by the operation for <see cref="Kind.WatchFiles"/>, or read by the operation for /// <see cref="Kind.UnwatchFiles"/>. /// </summary> private readonly OneOrMany<Context.RegularWatchedFile> _tokens; /// <summary> /// A collection of file paths to subscribe to for <see cref="Kind.WatchFiles"/> or /// <see cref="Kind.WatchDirectory"/>. /// </summary> private readonly OneOrMany<string> _paths; private WatcherOperation(Kind kind, string directory, ImmutableArray<string> filters, IVsFreeThreadedFileChangeEvents2 sink, List<uint> cookies) { Contract.ThrowIfFalse(kind is Kind.WatchDirectory); _kind = kind; _paths = new OneOrMany<string>(directory); _filters = filters.NullToEmpty(); _sink = sink; _cookies = cookies; // Other watching fields are not used for this kind _fileChangeFlags = 0; _tokens = OneOrMany<Context.RegularWatchedFile>.Empty; } private WatcherOperation(Kind kind, OneOrMany<string> files, _VSFILECHANGEFLAGS fileChangeFlags, IVsFreeThreadedFileChangeEvents2 sink, OneOrMany<Context.RegularWatchedFile> tokens) { Contract.ThrowIfFalse(kind is Kind.WatchFiles); _kind = kind; _paths = files; _fileChangeFlags = fileChangeFlags; _sink = sink; _tokens = tokens; // Other watching fields are not used for this kind _filters = []; _cookies = null!; } private WatcherOperation(Kind kind, List<uint> cookies) { Contract.ThrowIfFalse(kind is Kind.UnwatchDirectories); _kind = kind; _cookies = cookies; // Other watching fields are not used for this kind _filters = []; _fileChangeFlags = 0; _sink = null!; _tokens = OneOrMany<Context.RegularWatchedFile>.Empty; _paths = OneOrMany<string>.Empty; } private WatcherOperation(Kind kind, OneOrMany<Context.RegularWatchedFile> tokens) { Contract.ThrowIfFalse(kind is Kind.UnwatchFiles); _kind = kind; _tokens = tokens; // Other watching fields are not used for this kind _filters = []; _fileChangeFlags = 0; _sink = null!; _cookies = null!; _paths = OneOrMany<string>.Empty; } private enum Kind { WatchDirectory, WatchFiles, UnwatchDirectories, UnwatchFiles, } public static WatcherOperation WatchDirectory(string directory, ImmutableArray<string> filters, IVsFreeThreadedFileChangeEvents2 sink, List<uint> cookies) => new(Kind.WatchDirectory, directory, filters, sink, cookies); public static WatcherOperation WatchFile(string path, _VSFILECHANGEFLAGS fileChangeFlags, IVsFreeThreadedFileChangeEvents2 sink, Context.RegularWatchedFile token) => new(Kind.WatchFiles, OneOrMany.Create(path), fileChangeFlags, sink, OneOrMany.Create(token)); public static WatcherOperation WatchFiles(ImmutableArray<string> files, _VSFILECHANGEFLAGS fileChangeFlags, IVsFreeThreadedFileChangeEvents2 sink, ImmutableArray<Context.RegularWatchedFile> tokens) => new(Kind.WatchFiles, new OneOrMany<string>(files), fileChangeFlags, sink, new OneOrMany<Context.RegularWatchedFile>(tokens)); public static WatcherOperation UnwatchDirectories(List<uint> cookies) => new(Kind.UnwatchDirectories, cookies); public static WatcherOperation UnwatchFiles(ImmutableArray<Context.RegularWatchedFile> tokens) => new(Kind.UnwatchFiles, new OneOrMany<Context.RegularWatchedFile>(tokens)); public static WatcherOperation UnwatchFile(Context.RegularWatchedFile token) => new(Kind.UnwatchFiles, OneOrMany.Create(token)); /// <summary> /// Combines <see cref="WatcherOperation"/> instances between <paramref name="start"/> and <paramref name="end"/> /// in <paramref name="operations"/>. This input is assumed to have been pre-verified that all operations /// within this range are combinable. The resultant value, when applied, performs an operation /// equivalent to performing the specified range of operations consecutively. /// </summary> /// <param name="operations">The collection containing the operations to combine.</param> /// <param name="start">Start index (inclusive) of operations to combine.</param> /// <param name="end">End index (inclusive) of operations to combine.</param> public static WatcherOperation CombineRange(ImmutableSegmentedList<WatcherOperation> operations, int start, int end) { var firstOp = operations[start]; if (start == end) return firstOp; using var _1 = ArrayBuilder<Context.RegularWatchedFile>.GetInstance(out var tokensBuilder); using var _2 = ArrayBuilder<string>.GetInstance(out var fileNamesBuilder); using var _3 = ArrayBuilder<uint>.GetInstance(out var cookiesBuilder); for (; start <= end; start++) { var op = operations[start]; switch (op._kind) { case Kind.WatchFiles: for (var i = 0; i < op._paths.Count; i++) fileNamesBuilder.Add(op._paths[i]); for (var i = 0; i < op._tokens.Count; i++) tokensBuilder.Add(op._tokens[i]); break; case Kind.UnwatchFiles: for (var i = 0; i < op._tokens.Count; i++) tokensBuilder.Add(op._tokens[i]); break; case Kind.UnwatchDirectories: cookiesBuilder.AddRange(op._cookies); break; default: break; } } return firstOp._kind switch { Kind.WatchFiles => WatchFiles(fileNamesBuilder.ToImmutable(), firstOp._fileChangeFlags, firstOp._sink, tokensBuilder.ToImmutable()), Kind.UnwatchFiles => UnwatchFiles(tokensBuilder.ToImmutable()), Kind.UnwatchDirectories => UnwatchDirectories(cookiesBuilder.ToList()), _ => throw ExceptionUtilities.Unreachable() }; } public bool CanCombineWith(in WatcherOperation other) { // Watching directory operation cannot be combined if (_kind == Kind.WatchDirectory) return false; // Don't allow combining if either the kind or sink differ return _kind == other._kind && _sink == other._sink; } public async ValueTask ApplyAsync(IVsAsyncFileChangeEx2 service, CancellationToken cancellationToken) { switch (_kind) { case Kind.WatchDirectory: Contract.ThrowIfTrue(_paths.Count != 1); var cookie = await service.AdviseDirChangeAsync(_paths[0], watchSubdirectories: true, _sink, cancellationToken).ConfigureAwait(false); _cookies.Add(cookie); if (_filters.Length > 0) await service.FilterDirectoryChangesAsync(cookie, [.. _filters], cancellationToken).ConfigureAwait(false); return; case Kind.WatchFiles: var cookies = await service.AdviseFileChangesAsync(_paths.ToImmutable(), _fileChangeFlags, _sink, cancellationToken).ConfigureAwait(false); Contract.ThrowIfTrue(cookies.Length != _tokens.Count); for (var i = 0; i < cookies.Length; i++) _tokens[i].Cookie = cookies[i]; return; case Kind.UnwatchDirectories: Contract.ThrowIfFalse(_cookies is not null); await service.UnadviseDirChangesAsync(_cookies, cancellationToken).ConfigureAwait(false); return; case Kind.UnwatchFiles: await service.UnadviseFileChangesAsync(_tokens.Select(token => token.Cookie!.Value).ToArray(), cancellationToken).ConfigureAwait(false); return; default: throw new NotImplementedException(); } } } private sealed class Context : IVsFreeThreadedFileChangeEvents2, IFileChangeContext { private readonly FileChangeWatcher _fileChangeWatcher; private readonly ImmutableArray<WatchedDirectory> _watchedDirectories; /// <summary> /// Gate to guard mutable fields in this class and any mutation of any <see cref="RegularWatchedFile"/>s. /// </summary> private readonly object _gate = new(); private bool _disposed = false; private readonly HashSet<RegularWatchedFile> _activeFileWatchingTokens = []; /// <summary> /// The list of cookies we used to make watchers for <see cref="_watchedDirectories"/>. /// </summary> /// <remarks> /// This does not need to be used under <see cref="_gate"/>, as it's only used inside the actual queue of file watcher /// actions. /// </remarks> private readonly List<uint> _directoryWatchCookies = []; public Context(FileChangeWatcher fileChangeWatcher, ImmutableArray<WatchedDirectory> watchedDirectories) { _fileChangeWatcher = fileChangeWatcher; _watchedDirectories = watchedDirectories; foreach (var watchedDirectory in watchedDirectories) { var item = WatcherOperation.WatchDirectory( watchedDirectory.Path, watchedDirectory.ExtensionFilters, this, _directoryWatchCookies); _fileChangeWatcher._taskQueue.AddWork(item); } } public void Dispose() { lock (_gate) { if (_disposed) { return; } _disposed = true; } _fileChangeWatcher._taskQueue.AddWork(WatcherOperation.UnwatchDirectories(_directoryWatchCookies)); _fileChangeWatcher._taskQueue.AddWork(WatcherOperation.UnwatchFiles([.. _activeFileWatchingTokens])); } public IWatchedFile EnqueueWatchingFile(string filePath) { // If we already have this file under our path, we may not have to do additional watching if (WatchedDirectory.FilePathCoveredByWatchedDirectories(_watchedDirectories, filePath, StringComparison.OrdinalIgnoreCase)) return NoOpWatchedFile.Instance; var token = new RegularWatchedFile(this); lock (_gate) { _activeFileWatchingTokens.Add(token); } _fileChangeWatcher._taskQueue.AddWork(WatcherOperation.WatchFile(filePath, _VSFILECHANGEFLAGS.VSFILECHG_Size | _VSFILECHANGEFLAGS.VSFILECHG_Time, this, token)); return token; } private void StopWatchingFile(RegularWatchedFile watchedFile) { lock (_gate) { Contract.ThrowIfFalse(_activeFileWatchingTokens.Remove(watchedFile), "This token was no longer being watched."); } _fileChangeWatcher._taskQueue.AddWork(WatcherOperation.UnwatchFile(watchedFile)); } public event EventHandler<string>? FileChanged; int IVsFreeThreadedFileChangeEvents.FilesChanged(uint cChanges, string[] rgpszFile, uint[] rggrfChange) { for (var i = 0; i < cChanges; i++) { var fileChangeFlags = (_VSFILECHANGEFLAGS)rggrfChange[i]; if ((fileChangeFlags & FileChangeTracker.DefaultFileChangeFlags) == 0) continue; FileChanged?.Invoke(this, rgpszFile[i]); } return VSConstants.S_OK; } int IVsFreeThreadedFileChangeEvents.DirectoryChanged(string pszDirectory) { Debug.Fail("Since we're implementing IVsFreeThreadedFileChangeEvents2.DirectoryChangedEx2, this should not be called."); return VSConstants.E_NOTIMPL; } int IVsFreeThreadedFileChangeEvents2.DirectoryChanged(string pszDirectory) { Debug.Fail("Since we're implementing IVsFreeThreadedFileChangeEvents2.DirectoryChangedEx2, this should not be called."); return VSConstants.E_NOTIMPL; } int IVsFreeThreadedFileChangeEvents2.DirectoryChangedEx(string pszDirectory, string pszFile) { Debug.Fail("Since we're implementing IVsFreeThreadedFileChangeEvents2.DirectoryChangedEx2, this should not be called."); return VSConstants.E_NOTIMPL; } int IVsFreeThreadedFileChangeEvents2.DirectoryChangedEx2(string pszDirectory, uint cChanges, string[] rgpszFile, uint[] rggrfChange) { for (var i = 0; i < cChanges; i++) { var fileChangeFlags = (_VSFILECHANGEFLAGS)rggrfChange[i]; if ((fileChangeFlags & FileChangeTracker.DefaultFileChangeFlags) == 0) continue; FileChanged?.Invoke(this, rgpszFile[i]); } return VSConstants.S_OK; } int IVsFileChangeEvents.FilesChanged(uint cChanges, string[] rgpszFile, uint[] rggrfChange) { Debug.Fail("Since we're implementing IVsFreeThreadedFileChangeEvents2.FilesChanged, this should not be called."); return VSConstants.E_NOTIMPL; } int IVsFileChangeEvents.DirectoryChanged(string pszDirectory) => VSConstants.E_NOTIMPL; public sealed class RegularWatchedFile : IWatchedFile { public RegularWatchedFile(Context context) { _context = context; } private readonly Context _context; /// <summary> /// The cookie we have for requesting a watch on this file. Null means we either haven't /// done the subscription (and it's still in the queue) or we had some sort of error /// subscribing in the first place. /// </summary> public uint? Cookie; public void Dispose() { _context.StopWatchingFile(this); } } int IVsFreeThreadedFileChangeEvents2.FilesChanged(uint cChanges, string[] rgpszFile, uint[] rggrfChange) { for (var i = 0; i < cChanges; i++) { var fileChangeFlags = (_VSFILECHANGEFLAGS)rggrfChange[i]; if ((fileChangeFlags & FileChangeTracker.DefaultFileChangeFlags) == 0) continue; FileChanged?.Invoke(this, rgpszFile[i]); } return VSConstants.S_OK; } int IVsFreeThreadedFileChangeEvents.DirectoryChangedEx(string pszDirectory, string pszFile) { Debug.Fail("Since we're implementing IVsFreeThreadedFileChangeEvents2.DirectoryChangedEx2, this should not be called."); return VSConstants.E_NOTIMPL; } } }