Here is another approach. Instead of propagating the first event of a quick succession of events and suppressing all that follow, here all events are suppressed except from the last one. I think that the scenarios that can benefit from this approach are more common. The technical term for this strategy is debouncing.
To make this happen we must use a sliding delay. Every incoming event cancels the timer that would fire the previous event, and starts a new timer. This opens the possibility that a never-ending series of events will delay the propagation forever. To keep things simple, there is no provision for this abnormal case in the extension methods below.
public static class FileSystemWatcherExtensions
{
/// <summary>
/// Subscribes to the events specified by the 'changeTypes' argument.
/// The handler is not invoked, until an amount of time specified by the
/// 'dueTime' argument has elapsed after the last recorded event associated
/// with a specific file or directory (debounce behavior). The handler
/// is invoked with the 'FileSystemEventArgs' of the last recorded event.
/// </summary>
public static IDisposable OnAnyEvent(this FileSystemWatcher source,
WatcherChangeTypes changeTypes, FileSystemEventHandler handler,
TimeSpan dueTime)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(handler);
if (dueTime < TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(dueTime));
Dictionary<string, CancellationTokenSource> dictionary = new(
StringComparer.OrdinalIgnoreCase);
if (changeTypes.HasFlag(WatcherChangeTypes.Created))
source.Created += FileSystemWatcher_Event;
if (changeTypes.HasFlag(WatcherChangeTypes.Deleted))
source.Deleted += FileSystemWatcher_Event;
if (changeTypes.HasFlag(WatcherChangeTypes.Changed))
source.Changed += FileSystemWatcher_Event;
if (changeTypes.HasFlag(WatcherChangeTypes.Renamed))
source.Renamed += FileSystemWatcher_Event;
return new Disposable(() =>
{
source.Created -= FileSystemWatcher_Event;
source.Deleted -= FileSystemWatcher_Event;
source.Changed -= FileSystemWatcher_Event;
source.Renamed -= FileSystemWatcher_Event;
lock (dictionary)
{
foreach (CancellationTokenSource cts in dictionary.Values)
cts.Cancel();
dictionary.Clear();
}
});
async void FileSystemWatcher_Event(object sender, FileSystemEventArgs e)
{
string key = e.FullPath;
using (CancellationTokenSource cts = new())
{
lock (dictionary)
{
CancellationTokenSource existingCts;
dictionary.TryGetValue(key, out existingCts);
dictionary[key] = cts;
existingCts?.Cancel(); // Cancel the previous event
}
Task delayTask = Task.Delay(dueTime, cts.Token);
await Task.WhenAny(delayTask).ConfigureAwait(false); // No throw
if (delayTask.IsCanceled) return; // Preempted
lock (dictionary)
{
CancellationTokenSource existingCts;
dictionary.TryGetValue(key, out existingCts);
if (!ReferenceEquals(existingCts, cts)) return; // Preempted
dictionary.Remove(key); // Clean up before invoking the handler
}
}
// Invoke the handler the same way it's done in the .NET source code
ISynchronizeInvoke syncObj = source.SynchronizingObject;
if (syncObj != null && syncObj.InvokeRequired)
syncObj.BeginInvoke(handler, new object[] { sender, e });
else
handler(sender, e);
}
}
public static IDisposable OnAllEvents(this FileSystemWatcher source,
FileSystemEventHandler handler, TimeSpan delay)
=> OnAnyEvent(source, WatcherChangeTypes.All, handler, delay);
public static IDisposable OnCreated(this FileSystemWatcher source,
FileSystemEventHandler handler, TimeSpan delay)
=> OnAnyEvent(source, WatcherChangeTypes.Created, handler, delay);
public static IDisposable OnDeleted(this FileSystemWatcher source,
FileSystemEventHandler handler, TimeSpan delay)
=> OnAnyEvent(source, WatcherChangeTypes.Deleted, handler, delay);
public static IDisposable OnChanged(this FileSystemWatcher source,
FileSystemEventHandler handler, TimeSpan delay)
=> OnAnyEvent(source, WatcherChangeTypes.Changed, handler, delay);
public static IDisposable OnRenamed(this FileSystemWatcher source,
FileSystemEventHandler handler, TimeSpan delay)
=> OnAnyEvent(source, WatcherChangeTypes.Renamed, handler, delay);
private class Disposable : IDisposable
{
private Action _action;
public Disposable(Action action) => _action = action;
public void Dispose()
{
try { _action?.Invoke(); } finally { _action = null; }
}
}
}
Usage example:
IDisposable subscription = myWatcher.OnAnyEvent(
WatcherChangeTypes.Created | WatcherChangeTypes.Changed,
MyFileSystemWatcher_Event, TimeSpan.FromMilliseconds(100));
This line combines the subscription to two events, the Created
and the Changed
. So it is roughly equivalent to these:
myWatcher.Created += MyFileSystemWatcher_Event;
myWatcher.Changed += MyFileSystemWatcher_Event;
The difference is that the two events are regarded as a single type of event, and in case of a quick succession of these events only the last one will be propagated. For example if a Created
event is followed by two Changed
events, and there is no time gap larger than 100 msec between these three events, only the second Changed
event will be propagated by invoking the MyFileSystemWatcher_Event
handler, and the previous events will be discarded.
The IDisposable
return value can be used to unsubscribe from the events. Calling subscription.Dispose()
cancels and discards all recorded events, but it doesn't stop or wait for any handlers that are in the midst of their execution.
Specifically for the Renamed
event, the FileSystemEventArgs
argument can be cast to RenamedEventArgs
in order to access the extra information of this event. For example:
void MyFileSystemWatcher_Event(object s, FileSystemEventArgs e)
{
if (e is RenamedEventArgs re) Console.WriteLine(re.OldFullPath);
The debounced events are invoked on the FileSystemWatcher.SynchronizingObject
, if it has been configured, otherwise on the ThreadPool
. The invocation logic has been copy-pasted from the .NET 7 source code.