File: FileWatcher\PollingDirectoryWatcher.cs
Web Access
Project: src\src\sdk\src\Dotnet.Watch\Watch\Microsoft.DotNet.HotReload.Watch.csproj (Microsoft.DotNet.HotReload.Watch)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Immutable;
using System.Diagnostics;

namespace Microsoft.DotNet.Watch;

internal sealed class PollingDirectoryWatcher : DirectoryWatcher
{
    // The minimum interval to rerun the scan
    private static readonly TimeSpan _minRunInternal = TimeSpan.FromSeconds(.5);

    private readonly DirectoryInfo _watchedDirectory;

    private Dictionary<string, DateTime> _currentSnapshot = new(PathUtilities.OSSpecificPathComparer);

    // The following are sets that are used to calculate new snapshot and cleared on eached use (pooled):
    private Dictionary<string, DateTime> _snapshotBuilder = new(PathUtilities.OSSpecificPathComparer);
    private readonly Dictionary<string, ChangeKind> _changesBuilder = new(PathUtilities.OSSpecificPathComparer);

    private readonly Thread _pollingThread;
    private bool _raiseEvents;

    private volatile bool _disposed;

    public PollingDirectoryWatcher(string watchedDirectory, ImmutableHashSet<string> watchedFileNames, bool includeSubdirectories)
        : base(watchedDirectory, watchedFileNames, includeSubdirectories)
    {
        _watchedDirectory = new DirectoryInfo(watchedDirectory);

        _pollingThread = new Thread(new ThreadStart(PollingLoop))
        {
            IsBackground = true,
            Name = nameof(PollingDirectoryWatcher)
        };

        CaptureInitialSnapshot();

        _pollingThread.Start();
    }

    public override void Dispose()
    {
        EnableRaisingEvents = false;
        _disposed = true;
    }

    public override bool EnableRaisingEvents
    {
        get => _raiseEvents;
        set
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            _raiseEvents = value;
        }
    }

    private void PollingLoop()
    {
        var stopwatch = Stopwatch.StartNew();
        stopwatch.Start();

        while (!_disposed)
        {
            if (stopwatch.Elapsed < _minRunInternal)
            {
                // Don't run too often
                // The min wait time here can be double
                // the value of the variable (FYI)
                Thread.Sleep(_minRunInternal);
            }

            stopwatch.Reset();

            if (!_raiseEvents)
            {
                continue;
            }

            CheckForChangedFiles();
        }

        stopwatch.Stop();
    }

    private void CaptureInitialSnapshot()
    {
        Debug.Assert(_currentSnapshot.Count == 0);

        ForeachEntityInDirectory(_watchedDirectory, _currentSnapshot.Add);
    }

    private void CheckForChangedFiles()
    {
        Debug.Assert(_changesBuilder.Count == 0);
        Debug.Assert(_snapshotBuilder.Count == 0);

        ForeachEntityInDirectory(_watchedDirectory, (filePath, currentWriteTime) =>
        {
            if (!_currentSnapshot.TryGetValue(filePath, out var snapshotWriteTime))
            {
                _changesBuilder.TryAdd(filePath, ChangeKind.Add);
            }
            else if (snapshotWriteTime != currentWriteTime)
            {
                _changesBuilder.TryAdd(filePath, ChangeKind.Update);
            }

            _snapshotBuilder.Add(filePath, currentWriteTime);
        });

        foreach (var (filePath, _) in _currentSnapshot)
        {
            if (!_snapshotBuilder.ContainsKey(filePath))
            {
                _changesBuilder.TryAdd(filePath, ChangeKind.Delete);
            }
        }

        NotifyChanges(_changesBuilder);

        // Swap the two dictionaries
        (_snapshotBuilder, _currentSnapshot) = (_currentSnapshot, _snapshotBuilder);

        _changesBuilder.Clear();
        _snapshotBuilder.Clear();
    }

    private void ForeachEntityInDirectory(DirectoryInfo dirInfo, Action<string, DateTime> fileAction)
    {
        if (!dirInfo.Exists)
        {
            return;
        }

        IEnumerable<FileSystemInfo> entities;
        try
        {
            entities = dirInfo.EnumerateFileSystemInfos("*.*", SearchOption.TopDirectoryOnly);
        }
        // If the directory is deleted after the exists check this will throw and could crash the process
        catch (DirectoryNotFoundException)
        {
            return;
        }

        foreach (var entity in entities)
        {
            if (entity is DirectoryInfo subdirInfo)
            {
                if (IncludeSubdirectories)
                {
                    ForeachEntityInDirectory(subdirInfo, fileAction);
                }
            }
            else
            {
                string filePath;
                DateTime currentWriteTime;
                try
                {
                    filePath = entity.FullName;
                    currentWriteTime = entity.LastWriteTimeUtc;
                }
                catch (FileNotFoundException)
                {
                    continue;
                }

                fileAction(filePath, currentWriteTime);
            }
        }
    }

    private void NotifyChanges(Dictionary<string, ChangeKind> changes)
    {
        foreach (var (path, kind) in changes)
        {
            if (_disposed || !_raiseEvents)
            {
                break;
            }

            NotifyChange(path, kind);
        }
    }
}