mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-13 06:07:30 -04:00
A Windows CI leg failed with every test passing. FileLiberator.Tests
exited 0xE0434352:
Unhandled exception. System.InvalidOperationException: The collection has
been marked as complete with regards to additions.
at BlockingCollection`1.Add(T item)
at FileManager.BackgroundFileSystem.FileSystemWatcher_Changed(...)
at FileSystemWatcher.ReadDirectoryChangesCallback(...)
Stop() disposes the watcher and then completes the collection, on the
assumption that disposing stops events. It does not stop the ones the OS
has already buffered, and on Windows those arrive on a native completion
callback, where an exception is not a failed call - it is a dead process.
So a Libation run that reinitialises or shuts down its file cache while
the Books directory is busy can take the app with it, which is the same
race the tests hit.
Adding to a completed collection is now caught and the event dropped.
That is the right answer rather than a swallow: whoever called Stop() is
either reinitialising, which rebuilds the cache from disk, or disposing.
Stop() also detaches its handlers before disposing and clears the fields,
which narrows the window and makes a second Stop() harmless - the
collection field is cleared only after CompleteAdding, since the
background scanner is waiting on that.
First tests for the class, since it had none: dispose under a flood of
events, dispose twice, and find a file created before and after
construction. They cannot prove this fix - the crash does not reproduce on
Linux even with the original code, because inotify does not deliver
post-dispose events the way Windows does. Windows CI is the only place
that can, so the guard is aimed there.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
224 lines
5.9 KiB
C#
224 lines
5.9 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace FileManager;
|
|
|
|
/// <summary>
|
|
/// Tracks actual locations of files.
|
|
/// </summary>
|
|
public class BackgroundFileSystem : IDisposable
|
|
{
|
|
public LongPath? RootDirectory { get; private set; }
|
|
public string SearchPattern { get; private set; }
|
|
public SearchOption SearchOption { get; private set; }
|
|
|
|
private FileSystemWatcher? fileSystemWatcher { get; set; }
|
|
private BlockingCollection<FileSystemEventArgs>? directoryChangesEvents { get; set; }
|
|
private Task? backgroundScanner { get; set; }
|
|
|
|
private Lock fsCacheLocker { get; } = new();
|
|
private List<LongPath> fsCache { get; } = new();
|
|
|
|
public BackgroundFileSystem(LongPath rootDirectory, string searchPattern, SearchOption searchOptions)
|
|
{
|
|
RootDirectory = rootDirectory;
|
|
SearchPattern = searchPattern;
|
|
SearchOption = searchOptions;
|
|
|
|
Init();
|
|
}
|
|
|
|
public LongPath? FindFile(System.Text.RegularExpressions.Regex regex)
|
|
{
|
|
lock (fsCacheLocker)
|
|
return fsCache.FirstOrDefault(s => regex.IsMatch(s));
|
|
}
|
|
|
|
public List<LongPath> FindFiles(System.Text.RegularExpressions.Regex regex)
|
|
{
|
|
lock (fsCacheLocker)
|
|
return fsCache.Where(s => regex.IsMatch(s)).ToList();
|
|
}
|
|
|
|
public void RefreshFiles()
|
|
{
|
|
lock (fsCacheLocker)
|
|
{
|
|
fsCache.Clear();
|
|
if (Directory.Exists(RootDirectory))
|
|
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
|
|
}
|
|
}
|
|
|
|
private void Init()
|
|
{
|
|
Stop();
|
|
|
|
lock (fsCacheLocker)
|
|
{
|
|
if (!Directory.Exists(RootDirectory))
|
|
{
|
|
RootDirectory = null;
|
|
return;
|
|
}
|
|
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
|
|
}
|
|
|
|
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
|
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
|
|
{
|
|
IncludeSubdirectories = true,
|
|
EnableRaisingEvents = true
|
|
};
|
|
fileSystemWatcher.Created += FileSystemWatcher_Changed;
|
|
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
|
|
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
|
|
fileSystemWatcher.Error += FileSystemWatcher_Error;
|
|
|
|
backgroundScanner = new Task(BackgroundScanner);
|
|
backgroundScanner.Start();
|
|
}
|
|
private void Stop()
|
|
{
|
|
//Stop raising events. Detach first so a handler cannot run after this returns, and clear the field so a
|
|
//second Stop() is harmless.
|
|
var watcher = fileSystemWatcher;
|
|
fileSystemWatcher = null;
|
|
if (watcher is not null)
|
|
{
|
|
watcher.Created -= FileSystemWatcher_Changed;
|
|
watcher.Deleted -= FileSystemWatcher_Changed;
|
|
watcher.Renamed -= FileSystemWatcher_Changed;
|
|
watcher.Error -= FileSystemWatcher_Error;
|
|
watcher.Dispose();
|
|
}
|
|
|
|
try
|
|
{
|
|
//Calling CompleteAdding() will cause background scanner to terminate.
|
|
directoryChangesEvents?.CompleteAdding();
|
|
}
|
|
// if directoryChangesEvents is non-null and isDisposed, this exception is thrown. there's no other way to check >:(
|
|
catch (ObjectDisposedException) { }
|
|
|
|
//Wait for background scanner to terminate before reinitializing.
|
|
backgroundScanner?.Wait();
|
|
|
|
//Dispose of directoryChangesEvents after backgroundScanner exists. Clear the field first so a late event
|
|
//has nothing to add to. CompleteAdding has to have happened while it was still set, or the scanner would
|
|
//still be blocked waiting for it.
|
|
var events = directoryChangesEvents;
|
|
directoryChangesEvents = null;
|
|
events?.Dispose();
|
|
|
|
lock (fsCacheLocker)
|
|
fsCache.Clear();
|
|
}
|
|
|
|
private void FileSystemWatcher_Error(object sender, ErrorEventArgs e)
|
|
{
|
|
Init();
|
|
}
|
|
|
|
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
directoryChangesEvents?.Add(e);
|
|
}
|
|
// Stop() completes and disposes the collection, but events the OS had already buffered still arrive after
|
|
// that. On Windows they arrive on a native completion callback, where an exception does not fail a call -
|
|
// it takes the process down. Dropping them is correct: whoever called Stop() is either reinitializing,
|
|
// which rebuilds the cache from disk, or disposing.
|
|
//
|
|
// Covers both ways the collection refuses: completed for additions, and disposed, whose
|
|
// ObjectDisposedException derives from this.
|
|
catch (InvalidOperationException) { }
|
|
}
|
|
|
|
#region Background Thread
|
|
private void BackgroundScanner()
|
|
{
|
|
while (directoryChangesEvents?.TryTake(out var change, -1) is true)
|
|
{
|
|
lock (fsCacheLocker)
|
|
UpdateLocalCache(change);
|
|
}
|
|
}
|
|
|
|
private void UpdateLocalCache(FileSystemEventArgs change)
|
|
{
|
|
if (change.ChangeType == WatcherChangeTypes.Deleted)
|
|
{
|
|
RemovePath(change.FullPath);
|
|
}
|
|
else if (change.ChangeType == WatcherChangeTypes.Created)
|
|
{
|
|
AddPath(change.FullPath);
|
|
}
|
|
else if (change.ChangeType == WatcherChangeTypes.Renamed && change is RenamedEventArgs renameChange)
|
|
{
|
|
RemovePath(renameChange.OldFullPath);
|
|
AddPath(renameChange.FullPath);
|
|
}
|
|
}
|
|
|
|
private void RemovePath(LongPath path)
|
|
{
|
|
path = path.LongPathName;
|
|
var pathsToRemove = fsCache.Where(p => ((string)p).StartsWith(path)).ToArray();
|
|
|
|
foreach (var p in pathsToRemove)
|
|
fsCache.Remove(p);
|
|
}
|
|
|
|
private void AddPath(LongPath path)
|
|
{
|
|
path = path.LongPathName;
|
|
//Temporary files created when updating the db will disappear before their attributes can be read.
|
|
if (Path.GetFileName(path).Contains("LibationContext.db") || !File.Exists(path) && !Directory.Exists(path))
|
|
return;
|
|
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
|
|
AddUniqueFiles(SafestEnumerateFiles(path));
|
|
else
|
|
AddUniqueFile(path);
|
|
}
|
|
|
|
private IEnumerable<LongPath> SafestEnumerateFiles(string path)
|
|
{
|
|
try
|
|
{
|
|
return FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption);
|
|
}
|
|
catch
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private void AddUniqueFiles(IEnumerable<LongPath> newFiles)
|
|
{
|
|
foreach (var file in newFiles)
|
|
AddUniqueFile(file);
|
|
}
|
|
|
|
private void AddUniqueFile(LongPath newFile)
|
|
{
|
|
if (!fsCache.Contains(newFile))
|
|
fsCache.Add(newFile);
|
|
}
|
|
|
|
#endregion
|
|
|
|
public void Dispose()
|
|
{
|
|
Stop();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|