Files
Libation/Source/_Tests/FileManager.Tests/BackgroundFileSystemTests.cs
T
Cursor Agentandrmcrackan d27445a128 Stop a disposing file watcher from crashing the process
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>
2026-08-17 13:33:52 +00:00

106 lines
3.1 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;
}
}