#if UNITY_2021_1_OR_NEWER using Microsoft.Win32.SafeHandles; using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.IO.Enumeration; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; namespace FastScriptReload.Editor { /// /// This is a Windows only file watcher, for use in Unity/Mono. /// Mono already has the cross platform FileSystemWatcher. /// However, it's incredibly slow on Windows. /// This one is fast. /// /// This doesn't include the complete API surface of FileSystemWatcher, /// but those bits that are present should be compatible with FileSystemWatcher. /// /// Events will be dispatched on a worker thread. /// They may be on different threads from each other, but they won't overlap in time. /// /// There is an issue where there's a (small) chance that events may be missed. /// /// Firstly, this can happen if the event occurs in the brief time when previous events /// are being recorded, before listening can start again. /// This is not unique to this implementation - the Microsoft version has the same problem. /// The issue should actually be a little less bad here. Microsoft fires its events on the /// monitoring thread, and relies on the user to be smart about offloading them. /// We fire our events on a dedicated event thread. Long running handlers shouldn't /// pose a problem. /// /// Secondly, this can also happen if the internal buffer used by the Windows API overflows. /// It's set to the maximum size (which is larger than the Microsoft implementation default) /// but it could theoretically happen. /// /// The solution to both of these things, if we want to be completely robust, is to combine /// the file watcher with polling to catch rare missed events. /// internal sealed class WindowsFileSystemWatcher : IDisposable { public event FileSystemEventHandler Changed; public event FileSystemEventHandler Created; public event FileSystemEventHandler Deleted; public event RenamedEventHandler Renamed; public event ErrorEventHandler Error; public NotifyFilters NotifyFilter { get; set; } = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName; public string Filter { get; set; } = "*.*"; public bool IncludeSubdirectories { get; set; } = false; public string Path { get => this.path; set { var changed = value != this.path; this.path = value; // Restart if the path changes. if (changed && this.EnableRaisingEvents) { this.EnableRaisingEvents = false; this.EnableRaisingEvents = true; } } } private string path; private InterruptibleHandle currentHandle; private Task monitorTask; private Task eventsTask; private bool disposed = false; private readonly WeakDisposer weakDisposer; public WindowsFileSystemWatcher() { this.eventsTask = Task.CompletedTask; this.weakDisposer = new WeakDisposer(this); AppDomain.CurrentDomain.DomainUnload += this.weakDisposer.Dispose; #if UNITY_EDITOR UnityEditor.EditorApplication.quitting += this.weakDisposer.Dispose; UnityEditor.AssemblyReloadEvents.beforeAssemblyReload += this.weakDisposer.Dispose; #endif } // Note that the GC shouldn't ever destroy the FSW while it's running, // even if no references to it are retained by the user. // The monitoring thread holds a reference. ~WindowsFileSystemWatcher() { this.Dispose(); } public void Dispose() { if (this.disposed) return; this.disposed = true; GC.SuppressFinalize(this); this.EnableRaisingEvents = false; this.Changed = null; this.Created = null; this.Deleted = null; this.Renamed = null; this.Error = null; AppDomain.CurrentDomain.DomainUnload -= this.weakDisposer.Dispose; #if UNITY_EDITOR UnityEditor.EditorApplication.quitting -= this.weakDisposer.Dispose; UnityEditor.AssemblyReloadEvents.beforeAssemblyReload -= this.weakDisposer.Dispose; #endif } public bool EnableRaisingEvents { get => this.currentHandle != null; set { if (value) { if (this.disposed) throw new ObjectDisposedException(nameof(WindowsFileSystemWatcher)); if (this.currentHandle != null) return; this.currentHandle = CreateDirectoryHandle(this.Path); this.monitorTask = Task.Factory.StartNew(() => this.Monitor(this.currentHandle), TaskCreationOptions.LongRunning); } else { // This cancels scheduled-but-unrun events, because they don't run if the handle is closed. this.currentHandle?.Dispose(); this.currentHandle = null; this.monitorTask?.Wait(); this.monitorTask = null; // We don't wait for the events task, because we might be within the events task. // (Ooh, the events are coming from WITHIN THE TASK. Scary.) // This does leave a tiny chance that a single event could be triggered immediately after this, // if we're not in the events task... // TODO: Maybe fix this } } } private static InterruptibleHandle CreateDirectoryHandle(string directory) { const int FILE_LIST_DIRECTORY = 0x0001; const int FILE_SHARE_READ = 0x00000001; const int FILE_SHARE_WRITE = 0x00000002; const int FILE_SHARE_DELETE = 0x00000004; const int OPEN_EXISTING = 3; const int FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; // There might be a way to do this without the OS call? var directoryHandle = CreateFile( lpFileName: directory, dwDesiredAccess: FILE_LIST_DIRECTORY, dwShareMode: FILE_SHARE_READ | FILE_SHARE_DELETE | FILE_SHARE_WRITE, lpSecurityAttributes: null, dwCreationDisposition: OPEN_EXISTING, dwFlagsAndAttributes: FILE_FLAG_BACKUP_SEMANTICS, hTemplateFile: new SafeFileHandle(IntPtr.Zero, false) ); if (directoryHandle == null || directoryHandle.IsInvalid) throw new IOException("Failed to obtain handle for directory."); return new InterruptibleHandle(directoryHandle); } private unsafe void Monitor(InterruptibleHandle handle) { // We try to minimise the processing time taken by the monitoring thread. // The longer it takes, the more likely a buffer overflow. // (At our 64*1028 buffer size we're probably 99.9% fine anyway. Famous last words...) // We do this by pushing processing immediately to another thread. // We swap out the buffers instead of spending any time reading them. // This is probably a silly micro optimisation, but it feels like "the right way". const int MaxBufferPoolSize = 32; // Huge var bufferPool = new Stack(MaxBufferPoolSize); while (handle.IsOpen) { byte[] buffer; lock (bufferPool) if (!bufferPool.TryPop(out buffer)) buffer = new byte[64 * 1024]; fixed (byte* bufferPointer = buffer) { bool ok = false; int size = 0; try { ok = ReadDirectoryChangesW( hDirectory: handle, lpBuffer: new HandleRef(buffer, (IntPtr)bufferPointer), nBufferLength: buffer.Length, bWatchSubtree: this.IncludeSubdirectories ? 1 : 0, dwNotifyFilter: (int)this.NotifyFilter, lpBytesReturned: out size, overlappedPointer: null, lpCompletionRoutine: new HandleRef(null, IntPtr.Zero) ); } // The directory handle could be disposed from another thread. // That's fine, we'll just end. catch (ObjectDisposedException) { } catch (ArgumentNullException) { } catch (Exception ex) { DispatchError(ex); } if (!handle.IsOpen) break; if (!ok) DispatchError(new Win32Exception()); if (size == 0) DispatchError(new InternalBufferOverflowException($"Too many changes at once in directory: {this.Path}.")); } // Let's prevent event dispatches from overlapping or being out of order, // because this is closer to FileSystemWatcher's behaviour. // Overlapping/OOO would be a pretty easy way to get a nasty bug in user code. this.eventsTask = this.eventsTask.ContinueWith(_ => { this.ProcessBufferOnEventThread(handle, buffer); // Return to pool, preventing strange leak scenarios where the pool grows unreasonably large. // That could happen if the event threads run for a very long time. lock (bufferPool) { if (bufferPool.Count < MaxBufferPoolSize) bufferPool.Push(buffer); } }); } handle.Dispose(); void DispatchError(Exception ex) { this.eventsTask = this.eventsTask.ContinueWith(_ => this.Error?.Invoke(this, new ErrorEventArgs(ex))); } } private void ProcessBufferOnEventThread(InterruptibleHandle handle, ReadOnlySpan buffer) { ReadOnlySpan oldName = default; bool oldMatch = false; while (true) { if (handle == null || !handle.IsOpen) break; // We're dealing with file names as Spans for two reasons: // - FileSystemName wants them that way. // - We can avoid the string allocation for files that don't match. var nextEntryOffset = MemoryMarshal.Read(buffer); var action = MemoryMarshal.Read(buffer.Slice(4)); var nameLength = MemoryMarshal.Read(buffer.Slice(8)); var name = MemoryMarshal.Cast(buffer.Slice(12, nameLength)); buffer = buffer.Slice(nextEntryOffset); var match = string.IsNullOrEmpty(this.Filter) || FileSystemName.MatchesSimpleExpression(this.Filter, name); try { switch (action) { case Action.RenamedOld: oldName = name; oldMatch = match; break; case Action.RenamedNew: if (match | oldMatch) this.Renamed?.Invoke(this, new RenamedEventArgs(WatcherChangeTypes.Renamed, this.Path, name.ToString(), oldName.ToString())); break; default: if (!match) break; var nameStr = name.ToString(); switch (action) { case Action.Added: this.Created?.Invoke(this, new FileSystemEventArgs(WatcherChangeTypes.Created, this.Path, nameStr)); break; case Action.Modified: this.Changed?.Invoke(this, new FileSystemEventArgs(WatcherChangeTypes.Changed, this.Path, nameStr)); break; case Action.Removed: this.Deleted?.Invoke(this, new FileSystemEventArgs(WatcherChangeTypes.Deleted, this.Path, nameStr)); break; } break; } } catch (Exception ex) { this.Error?.Invoke(this, new ErrorEventArgs(new Exception("Exception in event handler.", ex))); } if (nextEntryOffset == 0) break; } } private class InterruptibleHandle : IDisposable { public SafeFileHandle Handle { get; } public bool IsOpen => !this.closed & !this.Handle.IsInvalid & !this.Handle.IsClosed; private bool closed; public InterruptibleHandle(SafeFileHandle handle) { this.Handle = handle; } ~InterruptibleHandle() { this.Dispose(); } public unsafe void Dispose() { this.closed = true; if (!(this.Handle.IsClosed)) CancelIoEx(this.Handle, null); this.Handle.Dispose(); GC.SuppressFinalize(this); } public static implicit operator SafeFileHandle(InterruptibleHandle handle) { return handle.Handle; } } /// /// Dispose handles are setup via weak references. /// We don't want the domain reload stuff to keep the FSW alive. /// Note that the FSW shouldn't be collected whilst running anyway. /// private sealed class WeakDisposer { private readonly WeakReference fsw; public WeakDisposer(WindowsFileSystemWatcher fsw) => this.fsw = new WeakReference(fsw); public void Dispose() { if (this.fsw.TryGetTarget(out var fsw)) fsw.Dispose(); } public void Dispose(object _, EventArgs __) => this.Dispose(); } #region Windows API private enum Action { Added = 1, Removed = 2, Modified = 3, RenamedOld = 4, RenamedNew = 5, } [DllImport("__Internal", CharSet = CharSet.Auto, BestFitMapping = false)] private static extern SafeFileHandle CreateFile( string lpFileName, int dwDesiredAccess, int dwShareMode, SECURITY_ATTRIBUTES lpSecurityAttributes, int dwCreationDisposition, int dwFlagsAndAttributes, SafeFileHandle hTemplateFile ); [DllImport("__Internal", EntryPoint = "ReadDirectoryChangesW", CharSet = CharSet.Auto, SetLastError = true)] private static extern unsafe bool ReadDirectoryChangesW( SafeFileHandle hDirectory, HandleRef lpBuffer, int nBufferLength, int bWatchSubtree, int dwNotifyFilter, out int lpBytesReturned, NativeOverlapped* overlappedPointer, HandleRef lpCompletionRoutine ); [DllImport("__Internal")] private static extern unsafe bool CancelIoEx(SafeHandle handle, NativeOverlapped* lpOverlapped); private class SECURITY_ATTRIBUTES { } #endregion } } #endif