Watching a directory: basic intro

Spread the love

Let’s say we want to write a .NET Core class that watches for file changes in a single directory. The implementation should only notify for explicitly subscribed files. For example, if DIR is being watched and DIR\file1.txt is subscribed, we should not receive any events for DIR\file2.txt, nor for DIR\INNER\file1.txt. Further, let’s presume that any time after subscribing, the caller could decide to unsubscribe. Finally, the entire watch procedure should be able to be stopped in one shot, so IDisposable support is a must.

Clearly, we would be wise to start from FileSystemWatcher. This gives us 90% of the solution already (basic file and folder change detection) and cross-platform support to boot! Given that FileSystemWatcher allows many complicated patterns with subdirectories, custom filters, different event types, and so on, we would rather create a façade — we’ll call it DirectoryWatcher.

Here is a basic implementation of DirectoryWatcher that should support the requirements:

    public sealed class DirectoryWatcher : IDisposable
    {
        private readonly string path;
        private readonly ConcurrentDictionary<string, Subscription> subscriptions;
        private readonly FileSystemWatcher watcher;

        public DirectoryWatcher(DirectoryInfo path)
        {
            this.path = path.FullName;
            this.subscriptions = new ConcurrentDictionary<string, Subscription>(StringComparer.OrdinalIgnoreCase);
            this.watcher = new FileSystemWatcher(this.path);
            this.watcher.NotifyFilter =
                NotifyFilters.FileName |
                NotifyFilters.LastWrite |
                NotifyFilters.CreationTime;
            this.watcher.Changed += (o, e) => this.OnUpdated(e.FullPath);
            this.watcher.Created += (o, e) => this.OnUpdated(e.FullPath);
            this.watcher.Renamed += (o, e) => this.OnUpdated(e.FullPath);
            this.watcher.EnableRaisingEvents = true;
        }

        public void Dispose() => this.watcher.Dispose();

        public IDisposable Subscribe(string file, Action<FileInfo> onUpdate)
        {
            FileInfo fullPath = new FileInfo(Path.Combine(this.path, file));
            if (!string.Equals(fullPath.DirectoryName, this.path, StringComparison.OrdinalIgnoreCase))
            {
                throw new ArgumentException(
                    "The file '{file}' is not directly within directory '{path}'.",
                    nameof(file));
            }

            string key = fullPath.FullName;
            Subscription subscription = this.subscriptions.GetOrAdd(
                key,
                k => new Subscription(fullPath, onUpdate, () => this.subscriptions.TryRemove(k, out _)));
            if (!object.ReferenceEquals(subscription.FullPath, fullPath))
            {
                throw new InvalidOperationException($"A subscription for '{key}' already exists.");
            }

            return subscription;
        }

        private void OnUpdated(string fullPath)
        {
            if (this.subscriptions.TryGetValue(fullPath, out Subscription subscription))
            {
                subscription.Invoke();
            }
        }

        private sealed class Subscription : IDisposable
        {
            private readonly Action<FileInfo> callback;
            private readonly Action onDispose;

            public Subscription(FileInfo fullPath, Action<FileInfo> callback, Action onDispose)
            {
                this.FullPath = fullPath;
                this.callback = callback;
                this.onDispose = onDispose;
            }

            public FileInfo FullPath { get; }

            public void Invoke() => this.callback(this.FullPath);

            public void Dispose() => this.onDispose();
        }
    }

To show it in action, we can write a sample app; here is the relevant calling code for using the DirectoryWatcher:

private static void RunWatcher(string[] files)
{
    using DirectoryWatcher watcher = new DirectoryWatcher(new DirectoryInfo("."));

    Action<FileInfo> onUpdated = f => Log($"Got an update for '{f.Name}'");

    IDisposable lastFile = null;
    foreach (string file in files)
    {
        Console.WriteLine($"Subscribing to '{file}'");
        lastFile = watcher.Subscribe(file, onUpdated);
    }

    Console.WriteLine("Press ENTER unsubscribe last file.");
    Console.ReadLine();

    using (lastFile)
    {
    }

    Console.WriteLine("Press ENTER to dispose.");
    Console.ReadLine();
}

Running the sample app gives us plausibly correct output:

Subscribing to 'file1.txt'
Subscribing to 'file2.txt'
Press ENTER unsubscribe last file.
[000.000/T5] ** delete 'file2.txt'
[000.577/T5] ** append 'file2.txt'
[000.579/T4] Got an update for 'file2.txt'
[000.584/T4] Got an update for 'file2.txt'
[001.171/T5] ** delete 'file2.txt'
[001.744/T5] ** delete 'file1.txt'
[001.824/T5] ** delete 'file2.txt'
[002.678/T5] ** move to 'file1.txt'
[002.686/T4] Got an update for 'file1.txt'
[002.687/T6] Got an update for 'file1.txt'
[002.711/T5] ** move to 'file2.txt'
[002.717/T6] Got an update for 'file2.txt'
[002.717/T4] Got an update for 'file2.txt'
[002.868/T5] ** overwrite 'file2.txt'
[002.868/T4] Got an update for 'file2.txt'
[002.873/T4] Got an update for 'file2.txt'
[003.806/T5] ** overwrite 'file2.txt'
[003.807/T4] Got an update for 'file2.txt'
[003.812/T4] Got an update for 'file2.txt'

Press ENTER to dispose.
[004.053/T5] ** append 'file1.txt'
[004.058/T4] Got an update for 'file1.txt'
[004.165/T5] ** append 'file1.txt'
[004.170/T4] Got an update for 'file1.txt'
[004.975/T5] ** overwrite 'file2.txt'
[005.288/T5] ** overwrite 'file2.txt'
[005.899/T5] ** move to 'file2.txt'
[006.557/T7] ** append 'file2.txt'
[006.974/T5] ** move to 'file2.txt'
[007.795/T5] ** move to 'file2.txt'
[008.741/T7] ** delete 'file2.txt'
[008.927/T5] ** overwrite 'file1.txt'
[008.928/T4] Got an update for 'file1.txt'
[008.932/T4] Got an update for 'file1.txt'

Press ENTER to quit.
[009.461/T5] ** overwrite 'file2.txt'
[010.100/T5] ** append 'file1.txt'
[010.293/T7] ** append 'file1.txt'
[010.438/T7] ** overwrite 'file1.txt'


DirectoryWatcherSample\DirectoryWatcherSample.App\bin\Debug\netcoreapp3.1\DirectoryWatcherSample.App.exe (process 13048) exited with code 0.
Press any key to close this window . . .

There are just a couple issues here. The first is evident in the program output, where we get more than one event for certain types of updates on a single file. Of course, this is well documented over the years by vexed users and insiders alike. The other issue is more of a self-inflicted design problem: the code is not easily testable since it has direct file system dependencies, and there is a lot of logic in there which has no platform dependencies that we want to be sure we got right.

Stay tuned for a few more posts which delve deeper into the design choices and address some of the issues raised. Until next time…

One thought on “Watching a directory: basic intro

  1. Pingback: Watching a directory: testability – WriteAsync .NET

Leave a Reply

Your email address will not be published. Required fields are marked *