| File: Workspace\ProjectSystem\ReferenceFileChangeTracker.cs | Web Access |
| Project: src\roslyn\src\Workspaces\Core\Portable\Microsoft.CodeAnalysis.Workspaces.csproj (Microsoft.CodeAnalysis.Workspaces) |
// 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.IO; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Collections; using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Threading; using Roslyn.Utilities; namespace Microsoft.CodeAnalysis.ProjectSystem; internal sealed class ReferenceFileChangeTracker : IDisposable { private readonly object _gate = new(); /// <summary> /// A file change context used to watch metadata references. This is lazy to avoid creating this immediately during /// our LSP process startup, when we don't yet know the LSP client's capabilities. /// </summary> private readonly Lazy<IFileChangeContext> _fileReferenceChangeContext; /// <summary> /// File watching tokens from <see cref="_fileReferenceChangeContext"/> that are watching metadata references. These /// are only created once we are actually applying a batch because we don't determine until the batch is applied if /// the file reference will actually be a file reference or it'll be a converted project reference. /// </summary> private readonly Dictionary<string, (IWatchedFile Token, int RefCount)> _referenceFileWatchingTokens = []; private readonly AsyncBatchingWorkQueue<string> _workQueue; private readonly Func<string, CancellationToken, Task> _callback; private bool _isDisposed; public ReferenceFileChangeTracker( IFileChangeWatcher fileChangeWatcher, IAsynchronousOperationListener asyncListener, Func<string, CancellationToken, Task> callback, CancellationToken cancellationToken) { _callback = callback; _workQueue = new AsyncBatchingWorkQueue<string>( TimeSpan.FromSeconds(5), ProcessWorkAsync, // Dedupe notifications for the same file path EqualityComparer<string>.Default, asyncListener, cancellationToken); _fileReferenceChangeContext = new Lazy<IFileChangeContext>(() => { var fileReferenceChangeContext = fileChangeWatcher.CreateContext(GetAdditionalWatchedDirectories()); fileReferenceChangeContext.FileChanged += (s, e) => _workQueue.AddWork(e); return fileReferenceChangeContext; }); static ImmutableArray<WatchedDirectory> GetAdditionalWatchedDirectories() { using var _ = PooledHashSet<string>.GetInstance(out var referenceDirectories); // On each platform, there is a place that reference assemblies for the framework are installed. These are // rarely going to be changed but are the most common places that we're going to create file watches. Rather // than either creating a huge number of file watchers for every single file, or eventually realizing we // should just watch these directories, we just create the single directory watchers now. We'll collect this // from two places: constructing it from known environment variables, and also for the defaults where those // environment variables would usually point, as a fallback. if (Environment.GetEnvironmentVariable("DOTNET_ROOT") is string dotnetRoot && !string.IsNullOrEmpty(dotnetRoot)) { referenceDirectories.Add(Path.Combine(dotnetRoot, "packs")); } if (PlatformInformation.IsWindows) { referenceDirectories.Add(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Reference Assemblies", "Microsoft", "Framework")); referenceDirectories.Add(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "packs")); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { referenceDirectories.Add("/usr/lib/dotnet/packs"); referenceDirectories.Add("/usr/share/dotnet/packs"); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { referenceDirectories.Add("/usr/local/share/dotnet/packs"); } return referenceDirectories.SelectAsArray(static d => new WatchedDirectory(d, [".dll"])); } } /// <summary> /// Starts watching a particular reference for changes to the file. If this is already being /// watched , the reference count will be incremented. This is *not* safe to attempt to call multiple times for the /// same project and reference (e.g. in applying workspace updates) /// </summary> public void StartWatchingReference(string fullFilePath) { lock (_gate) { if (_isDisposed) return; var (token, count) = _referenceFileWatchingTokens.GetOrAdd(fullFilePath, _ => { var fileToken = _fileReferenceChangeContext.Value.EnqueueWatchingFile(fullFilePath); return (fileToken, RefCount: 0); }); _referenceFileWatchingTokens[fullFilePath] = (token, RefCount: count + 1); } } /// <summary> /// Decrements the reference count for the given reference. When the reference count reaches /// 0, the file watcher will be stopped. This is *not* safe to attempt to call multiple times for the same project /// and reference (e.g. in applying workspace updates) /// </summary> public void StopWatchingReference(string fullFilePath) { lock (_gate) { if (_isDisposed) return; if (!_referenceFileWatchingTokens.TryGetValue(fullFilePath, out var watchedFileReference)) throw new ArgumentException("Attempting to stop watching a file that we never started watching. This is a bug."); var newRefCount = watchedFileReference.RefCount - 1; Contract.ThrowIfFalse(newRefCount >= 0, "Ref count cannot be negative"); if (newRefCount == 0) { // No one else is watching this file, so stop watching it and remove from our map. watchedFileReference.Token.Dispose(); _referenceFileWatchingTokens.Remove(fullFilePath); } else { _referenceFileWatchingTokens[fullFilePath] = (watchedFileReference.Token, newRefCount); } // Note we still potentially have an outstanding change that we haven't raised a notification for due to the // delay we use. We could cancel the notification for that file path, but we may still have another // outstanding PortableExecutableReference that isn't this one that does want that notification. We're OK // just leaving the delay still running for two reasons: // // 1. Technically, we did see a file change before the call to StopWatchingReference, so arguably we should // still raise it. // 2. Since we raise the notification for a file path, it's up to the consumer of this to still track down // which actual reference needs to be changed. That'll automatically handle any race where the event // comes late, which is a scenario this must always deal with no matter what -- another thread might // already be gearing up to notify the caller of this reference and we can't stop it. } } private async ValueTask ProcessWorkAsync(ImmutableSegmentedList<string> list, CancellationToken cancellationToken) { foreach (var filePath in list) { cancellationToken.ThrowIfCancellationRequested(); await _callback(filePath, cancellationToken).ConfigureAwait(false); } } public void Dispose() { lock (_gate) { if (_isDisposed) return; _workQueue.Dispose(); _isDisposed = true; if (_fileReferenceChangeContext.IsValueCreated) _fileReferenceChangeContext.Value.Dispose(); } } }