Files
Libation/Source/_Tests/FileManager.Tests/BackgroundFileSystemTests.cs
T
Cursor Agentandrmcrackan 7cb58c7912 fix: one unreadable path no longer stops the file cache tracking anything
All three Windows CI legs failed on master while the other six passed, and not on
an assertion: every test in FileLiberator.Tests' PDF path suite failed in
TestInitialize with an AggregateException wrapping FileNotFoundException, naming a
path none of those tests had anything to do with.

The watcher had raised Created for a folder an earlier test's cleanup then
deleted. AddPath asked whether the path existed, was told yes, asked what it was,
and got an exception - Exists and GetAttributes can disagree over a long \\?\
path, and the answer to the first can stop being true before the second is asked
anyway. That exception ended the background scanner, so nothing further reached
the cache, and it was stored on the task, so the next Stop() rethrew it as an
AggregateException at whoever had called Refresh(). In the app that caller is the
Books directory refresh after every download.

Three changes, smallest first: the attribute read is guarded and returns 'nothing
to add' where the existence check used to say it, which also removes the race
rather than narrowing it; the scanner survives an event it cannot apply; and
Stop() waits on a scanner that has already failed without handing the failure to
a caller that is about to replace it.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 20:17:23 +00:00

235 lines
7.8 KiB
C#

using FileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading;
namespace BackgroundFileSystemTests;
/// <summary>
/// The first tests for this class, prompted by a CI run where every test passed and the run still failed:
/// FileLiberator.Tests exited with 0xE0434352 and
/// <c>InvalidOperationException: The collection has been marked as complete with regards to additions</c>,
/// thrown from FileSystemWatcher_Changed. Disposing while the OS still had events buffered took the process
/// down, because on Windows those arrive on a native completion callback where nothing catches anything.
/// </summary>
[TestClass]
[DoNotParallelize]
public class DisposeWhileEventsAreArriving
{
private string tempDir = string.Empty;
[TestInitialize]
public void Initialize()
{
tempDir = Path.Combine(Path.GetTempPath(), $"libation-bfs-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
}
[TestCleanup]
public void Cleanup()
{
try
{
Directory.Delete(tempDir, recursive: true);
}
catch (IOException)
{
// A leftover temp directory is not worth failing a test over.
}
}
/// <summary>
/// Churns the directory hard enough that events are in flight, then disposes underneath them. The assertion
/// that matters is that the process is still alive afterwards: before the fix this crashed the test host
/// rather than failing anything.
/// </summary>
[TestMethod]
public void disposing_under_a_flood_of_events_does_not_crash()
{
for (var attempt = 0; attempt < 20; attempt++)
{
var sut = new BackgroundFileSystem(tempDir, "*.*", SearchOption.AllDirectories);
for (var i = 0; i < 50; i++)
File.WriteAllText(Path.Combine(tempDir, $"file-{attempt}-{i}.txt"), "x");
// no wait: the point is to dispose while the watcher still has events to deliver
sut.Dispose();
foreach (var file in Directory.GetFiles(tempDir))
File.Delete(file);
}
// give any late callback the chance to arrive and take the process with it
Thread.Sleep(250);
}
[TestMethod]
public void disposing_twice_is_harmless()
{
var sut = new BackgroundFileSystem(tempDir, "*.*", SearchOption.AllDirectories);
sut.Dispose();
sut.Dispose();
}
[TestMethod]
public void files_present_before_and_after_construction_are_both_found()
{
File.WriteAllText(Path.Combine(tempDir, "before.txt"), "x");
using var sut = new BackgroundFileSystem(tempDir, "*.*", SearchOption.AllDirectories);
Assert.IsNotNull(sut.FindFile(new Regex(@"before\.txt$")));
File.WriteAllText(Path.Combine(tempDir, "after.txt"), "x");
// the watcher feeds a background scanner, so the new file appears when it gets there
var found = WaitFor(() => sut.FindFile(new Regex(@"after\.txt$")) is not null);
Assert.IsTrue(found, "a file created after construction never reached the cache");
}
private static bool WaitFor(Func<bool> condition, int timeoutMs = 5000)
{
for (var waited = 0; waited < timeoutMs; waited += 50)
{
if (condition())
return true;
Thread.Sleep(50);
}
return false;
}
}
/// <summary>
/// A second failure mode in the same class, from a CI run where all three Windows legs failed and the other six
/// passed: every test in FileLiberator.Tests' PDF path suite failed in TestInitialize with an AggregateException
/// wrapping <c>FileNotFoundException: Could not find file ...</c>, naming a path none of those tests had
/// anything to do with. The watcher had raised Created for a folder an earlier test's cleanup then deleted; the
/// scanner asked whether it existed, was told yes, asked what it was, and got an exception. That killed the
/// scanner and was stored on its task, and the next Stop() - reached from AudibleFileStorage.Audio.Refresh() by
/// way of Dispose() - rethrew it at that caller.
/// <para>
/// The trigger is a disagreement between Exists and GetAttributes over a long <c>\\?\</c> path, which cannot be
/// staged on the platform these tests usually run on. So the guard itself is asserted directly, and the rest
/// covers what the death cost: a cache that stops tracking, and an exception handed to an unrelated caller.
/// </para>
/// </summary>
[TestClass]
[DoNotParallelize]
public class PathsThatVanishBeforeTheyAreRead
{
private string tempDir = string.Empty;
[TestInitialize]
public void Initialize()
{
tempDir = Path.Combine(Path.GetTempPath(), $"libation-bfs-vanishing-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
}
[TestCleanup]
public void Cleanup()
{
try
{
Directory.Delete(tempDir, recursive: true);
}
catch (IOException)
{
// A leftover temp directory is not worth failing a test over.
}
}
/// <summary>Creates a batch of files and deletes the tree while their events are still queued.</summary>
private void ChurnVanishingFiles()
{
for (var batch = 0; batch < 5; batch++)
{
var directory = Path.Combine(tempDir, $"vanishing-{batch}");
Directory.CreateDirectory(directory);
for (var i = 0; i < 40; i++)
File.WriteAllText(Path.Combine(directory, $"book-{i}.m4b"), "audio");
// No wait: the scanner should reach these paths after they have gone.
Directory.Delete(directory, recursive: true);
}
}
[TestMethod]
public void a_path_that_is_not_there_reads_as_nothing_rather_than_throwing()
{
// The fix, on its own terms. Asking what a path is used to be allowed to throw, on the strength of having
// asked a moment earlier whether it was there.
Assert.IsNull(BackgroundFileSystem.TryGetAttributes(Path.Combine(tempDir, "never-existed.m4b")));
Assert.IsNull(BackgroundFileSystem.TryGetAttributes(Path.Combine(tempDir, "no", "such", "folder", "book.m4b")));
}
[TestMethod]
public void a_path_that_is_there_still_reads_as_what_it_is()
{
var file = Path.Combine(tempDir, "real.m4b");
File.WriteAllText(file, "audio");
Assert.IsFalse(BackgroundFileSystem.TryGetAttributes(file)!.Value.HasFlag(FileAttributes.Directory));
Assert.IsTrue(BackgroundFileSystem.TryGetAttributes(tempDir)!.Value.HasFlag(FileAttributes.Directory));
}
[TestMethod]
public void the_scanner_keeps_going_after_a_path_it_cannot_read()
{
using var sut = new BackgroundFileSystem(tempDir, "*.*", SearchOption.AllDirectories);
ChurnVanishingFiles();
// The scanner is still doing its job, which is what dying used to cost: the first unreadable path ended
// the loop, and every later change to the Books directory went unnoticed for the rest of the session.
File.WriteAllText(Path.Combine(tempDir, "survivor.m4b"), "audio");
var found = WaitFor(() => sut.FindFile(new Regex(@"survivor\.m4b$")) is not null);
Assert.IsTrue(found, "the scanner stopped tracking changes after a path it could not read");
}
[TestMethod]
public void disposing_afterwards_does_not_hand_the_failure_to_the_caller()
{
// This is the CI stack: Refresh() found the Books directory changed, disposed the old file system, and
// Stop() waited on a scanner that had already faulted.
var sut = new BackgroundFileSystem(tempDir, "*.*", SearchOption.AllDirectories);
ChurnVanishingFiles();
sut.Dispose();
}
[TestMethod]
public void a_file_that_outlives_its_event_is_still_tracked()
{
// The guard must not have been widened into ignoring everything: what is really there still lands.
using var sut = new BackgroundFileSystem(tempDir, "*.*", SearchOption.AllDirectories);
var directory = Path.Combine(tempDir, "kept");
Directory.CreateDirectory(directory);
File.WriteAllText(Path.Combine(directory, "kept.m4b"), "audio");
var found = WaitFor(() => sut.FindFile(new Regex(@"kept\.m4b$")) is not null);
Assert.IsTrue(found, "a file that was never deleted did not reach the cache");
}
private static bool WaitFor(Func<bool> condition, int timeoutMs = 5000)
{
for (var waited = 0; waited < timeoutMs; waited += 50)
{
if (condition())
return true;
Thread.Sleep(50);
}
return false;
}
}