From 688799419b3d55457c90f2f37a8b7c195c56d215 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Mon, 11 May 2026 00:13:06 +0200 Subject: [PATCH 1/8] implement livevirtual strategy --- .../FileExistsStrategyTestBase.cs | 20 +- .../LiveVirtualFileExistsStrategyTests.cs | 356 ++++++++++++++++++ .../VirtualFileExistsStrategyBaseTests.cs | 122 ++++++ .../VirtualFileExistsStrategyTests.cs | 120 +----- ...leExistsStrategy_RootGameDirectoryTests.cs | 59 +++ .../WindowsFileExistsStrategyTests.cs | 8 + .../WineFileExistsStrategyTests.cs | 4 + .../PetroglyphFileSystemTests.UseStrategy.cs | 17 +- .../LiveVirtualFileExistsStrategy.cs | 159 ++++++++ .../FileExistStrategies/VirtualDirectory.cs | 11 +- .../VirtualFileExistsStrategy.cs | 158 +------- .../VirtualFileExistsStrategyBase.cs | 162 ++++++++ .../IO/PetroglyphFileSystem.Strategies.cs | 45 +++ 13 files changed, 965 insertions(+), 276 deletions(-) create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs index feb8e62..005cc4a 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs @@ -3,6 +3,7 @@ using System.IO.Abstractions; using System.Runtime.InteropServices; using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; using PG.StarWarsGame.Engine.Utilities; using Testably.Abstractions; using Xunit; @@ -20,6 +21,21 @@ protected override IFileSystem CreateFileSystem() protected abstract override void ConfigureStrategy(PetroglyphFileSystem fs); + /// + /// Constructs a fresh, undisposed instance of the strategy under test, so generic suite + /// tests () can exercise it directly without + /// fighting the 's ownership of the active strategy. + /// + private protected abstract FileExistsStrategy CreateStrategyForDisposeTest(); + + [Fact] + public void Dispose_CalledTwice_DoesNotThrow() + { + var strategy = CreateStrategyForDisposeTest(); + strategy.Dispose(); + strategy.Dispose(); + } + protected virtual void AssertResolvedPath(string expectedOnDiskPath, string actualResult) { var expected = expectedOnDiskPath.Replace('\\', FileSystem.Path.DirectorySeparatorChar).Replace('/', FileSystem.Path.DirectorySeparatorChar); @@ -53,10 +69,6 @@ protected string NewTempDir() return dir; } - // --------------------------------------------------------------------------------------------- - // Shared tests — every strategy must satisfy. - // --------------------------------------------------------------------------------------------- - [Theory] [InlineData("/gameDir")] [InlineData(null)] diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs new file mode 100644 index 0000000..b47bead --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Abstractions; +using System.Reflection; +using System.Threading.Tasks; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +#if Windows +public sealed class LiveVirtualFileExistsStrategy_Windows : LiveVirtualFileExistsStrategyTests +{ + protected override void ConfigureStrategy(PetroglyphFileSystem fs) + { + fs.UseLiveVirtualStrategy(new WineFileExistsStrategy(fs.UnderlyingFileSystem)); + } +} +#endif + +public sealed class LiveVirtualFileExistsStrategy_Wine : LiveVirtualFileExistsStrategyTests +{ + protected override void ConfigureStrategy(PetroglyphFileSystem fs) + { + fs.UseLiveVirtualStrategy(new WineFileExistsStrategy(fs.UnderlyingFileSystem)); + } +} + +public abstract class LiveVirtualFileExistsStrategyTests : VirtualFileExistsStrategyBaseTests +{ + /// + /// Hard cap on how long we'll wait for the OS to deliver a watcher event. The OS delivers + /// events asynchronously; we poll the cache state at until the + /// expected condition holds, only failing if the deadline passes. + /// + private static readonly TimeSpan WatcherEventTimeout = TimeSpan.FromSeconds(30); + + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); + + private protected override void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying) + => fs.UseLiveVirtualStrategy(underlying); + + private protected override FileExistsStrategy CreateStrategyForDisposeTest() + => new LiveVirtualFileExistsStrategy(FileSystem, new WineFileExistsStrategy(FileSystem)); + + [Fact] + public async Task FileExists_AfterFileDeletedOnDisk_ReportsMissing() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(file), + () => !FileExists("Data/foo.xml".AsSpan(), dir.AsSpan()), + "snapshot to refresh after Data/foo.xml was deleted on disk"); + } + + [Fact] + public async Task FileExists_AfterFileCreatedOnDisk_ReportsPresent() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "seed.xml"), "x"); + + // Prime the snapshot. + Assert.True(FileExists("Data/seed.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/new.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "new.xml"), "y"), + () => FileExists("Data/new.xml".AsSpan(), dir.AsSpan()), + "snapshot to refresh after Data/new.xml was created on disk"); + } + + [Fact] + public async Task FileExists_AfterFileRenamed_OldNameMissingNewNamePresent() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var oldPath = FileSystem.Path.Combine(dataDir, "old.xml"); + var newPath = FileSystem.Path.Combine(dataDir, "new.xml"); + FileSystem.File.WriteAllText(oldPath, "x"); + + Assert.True(FileExists("Data/old.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/new.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Move(oldPath, newPath), + () => !FileExists("Data/old.xml".AsSpan(), dir.AsSpan()) + && FileExists("Data/new.xml".AsSpan(), dir.AsSpan()), + "snapshot to reflect the rename of Data/old.xml to Data/new.xml"); + } + + [Fact] + public async Task FileExists_AfterDirectoryRenamed_OldPathMissingNewPathPresent() + { + var dir = NewTempDir(); + var oldDir = FileSystem.Path.Combine(dir, "OldData"); + var newDir = FileSystem.Path.Combine(dir, "NewData"); + FileSystem.Directory.CreateDirectory(oldDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(oldDir, "foo.xml"), "x"); + + Assert.True(FileExists("OldData/foo.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.Directory.Move(oldDir, newDir), + () => !FileExists("OldData/foo.xml".AsSpan(), dir.AsSpan()) + && FileExists("NewData/foo.xml".AsSpan(), dir.AsSpan()), + "cached descendants of OldData to invalidate after directory rename"); + } + + [Fact] + public async Task FileExists_AfterDirectoryDeleted_AllDescendantsInvalidated() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + var subDir = FileSystem.Path.Combine(dataDir, "Sub"); + FileSystem.Directory.CreateDirectory(subDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "a.xml"), "1"); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(subDir, "b.xml"), "2"); + + Assert.True(FileExists("Data/a.xml".AsSpan(), dir.AsSpan())); + Assert.True(FileExists("Data/Sub/b.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.Directory.Delete(dataDir, recursive: true), + () => !FileExists("Data/a.xml".AsSpan(), dir.AsSpan()) + && !FileExists("Data/Sub/b.xml".AsSpan(), dir.AsSpan()), + "cached descendants of Data/ to invalidate after recursive directory delete"); + } + + [Fact] + public void SwapStrategy_LiveThenWineThenLive_FreshUnderlyingHandlesOutOfBaseLookups() + { + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "gameDir"); + var outsideDir = FileSystem.Path.Combine(root, "outside"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(outsideDir); + var insideFile = FileSystem.Path.Combine(gameDir, "in.xml"); + var outsideFile = FileSystem.Path.Combine(outsideDir, "out.xml"); + FileSystem.File.WriteAllText(insideFile, "i"); + FileSystem.File.WriteAllText(outsideFile, "o"); + + // First Live, with trackingA as the out-of-base fallback. + var trackingA = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = outsideFile }; + PgFileSystem.UseLiveVirtualStrategy(trackingA); + + Assert.True(FileExists("in.xml".AsSpan(), gameDir.AsSpan())); // snapshot path, no delegation + Assert.True(FileExists(outsideFile.AsSpan(), gameDir.AsSpan())); // out-of-base → trackingA + Assert.Equal(1, trackingA.CallCount); + + // Swap to Wine. SwapStrategy disposes the previous Live, which also disposes trackingA. + PgFileSystem.UseWineStrategy(); + + // Swap back to Live with a brand-new tracking underlying. + var trackingB = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = outsideFile }; + PgFileSystem.UseLiveVirtualStrategy(trackingB); + + // Out-of-base lookup must be routed through the NEW underlying. The old trackingA must + // not be touched anymore — this is the assertion that catches stale references. + Assert.True(FileExists(outsideFile.AsSpan(), gameDir.AsSpan())); + Assert.Equal(1, trackingA.CallCount); + Assert.Equal(1, trackingB.CallCount); + + // And the second Live owns its own snapshot store, so in-base lookups still bypass the + // underlying. + Assert.True(FileExists("in.xml".AsSpan(), gameDir.AsSpan())); + Assert.Equal(1, trackingB.CallCount); + } + + [Fact] + public async Task FileExists_TracksMultipleBaseDirectoriesIndependently() + { + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "gameDir"); + var workshopDir = FileSystem.Path.Combine(root, "workshops", "myMod"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(workshopDir); + var gameFile = FileSystem.Path.Combine(gameDir, "game.xml"); + var workshopFile = FileSystem.Path.Combine(workshopDir, "mod.xml"); + FileSystem.File.WriteAllText(gameFile, "x"); + FileSystem.File.WriteAllText(workshopFile, "y"); + + // Prime watchers for both base directories. + Assert.True(FileExists("game.xml".AsSpan(), gameDir.AsSpan())); + Assert.True(FileExists("mod.xml".AsSpan(), workshopDir.AsSpan())); + + // A change under the gameDir base must invalidate the gameDir snapshot… + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(gameFile), + () => !FileExists("game.xml".AsSpan(), gameDir.AsSpan()), + "gameDir snapshot to refresh after game.xml deleted"); + + // …but the workshop snapshot must still be live and serve mod.xml unchanged. + Assert.True(FileExists("mod.xml".AsSpan(), workshopDir.AsSpan())); + + // And the converse — deleting under workshopDir must update only that base's snapshot. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(workshopFile), + () => !FileExists("mod.xml".AsSpan(), workshopDir.AsSpan()), + "workshopDir snapshot to refresh after mod.xml deleted"); + } + + [Fact] + public async Task FileExists_NewDirectoryUnderTrackedBase_FirstLookupSnapshotsThenCacheServes() + { + var dir = NewTempDir(); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dir, "seed.xml"), "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + // Prime the watcher on the base directory. + Assert.True(FileExists("seed.xml".AsSpan(), dir.AsSpan())); + + // Create a new directory + file under the watched base after the watcher is up. + var newDir = FileSystem.Path.Combine(dir, "NewDir"); + FileSystem.Directory.CreateDirectory(newDir); + var newFile = FileSystem.Path.Combine(newDir, "foo.xml"); + FileSystem.File.WriteAllText(newFile, "y"); + + // Wait for the watcher to invalidate the base directory's cache after the create. + await AwaitCacheInvalidationAsync( + () => { /* disk action already done */ }, + () => FileExists("NewDir/foo.xml".AsSpan(), dir.AsSpan()), + "first lookup of NewDir/foo.xml to succeed against the freshly-snapshotted directory"); + + var afterFirstLookup = tracking.CallCount; + + // Second lookup — the snapshot for NewDir is now in the store, so this is a cache hit. + // Neither the underlying tracking strategy nor the disk should be re-consulted. + Assert.True(FileExists("NewDir/foo.xml".AsSpan(), dir.AsSpan())); + Assert.Equal(afterFirstLookup, tracking.CallCount); + + // Underlying must never be called for in-base-dir paths regardless of lookup count. + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public async Task WatcherError_BrokenWatcher_StrategyRecoversAndKeepsTrackingOnNextLookup() + { + // There's no portable way to make a real FileSystemWatcher fire Error (buffer overflow + // is flaky/slow; root deletion is OS-dependent), so we synthesize the Error path by + // invoking the strategy's private handler with the live watcher as sender. We do not + // assert any internal state — only that the strategy keeps doing its job: lookups still + // resolve, and subsequent disk changes are still picked up via a re-armed watcher. + var baseDir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(baseDir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); + + // Prime: the live strategy installs a watcher and snapshots the directory. + Assert.True(FileExists("Data/foo.xml".AsSpan(), baseDir.AsSpan())); + + var strategy = GetActiveLiveStrategy(); + InvokeOnWatcherError(strategy, GetWatchers(strategy)[baseDir], new ErrorEventArgs(new IOException("simulated"))); + + // 1) Lookups still resolve correctly after the Error. + Assert.True(FileExists("Data/foo.xml".AsSpan(), baseDir.AsSpan())); + + // 2) The strategy keeps tracking: a subsequent disk change is still reflected. + // (Implicitly verifies the next lookup re-armed a working watcher.) + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(file), + () => !FileExists("Data/foo.xml".AsSpan(), baseDir.AsSpan()), + "snapshot to invalidate after Data/foo.xml deleted (post-Error rebuild)"); + } + + [Fact] + public async Task WatcherError_OneOfManyRoots_OtherRootStillTracksChanges() + { + // An Error on one root must not impair the strategy's ability to track changes + // under unrelated roots, nor prevent the broken root from recovering on next use. + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "gameDir"); + var workshopDir = FileSystem.Path.Combine(root, "workshops", "myMod"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(workshopDir); + var gameFile = FileSystem.Path.Combine(gameDir, "g.xml"); + var workshopFile = FileSystem.Path.Combine(workshopDir, "m.xml"); + FileSystem.File.WriteAllText(gameFile, "g"); + FileSystem.File.WriteAllText(workshopFile, "m"); + + // Prime both bases. + Assert.True(FileExists("g.xml".AsSpan(), gameDir.AsSpan())); + Assert.True(FileExists("m.xml".AsSpan(), workshopDir.AsSpan())); + + // Synthesize an Error on the gameDir watcher only. + var strategy = GetActiveLiveStrategy(); + InvokeOnWatcherError(strategy, GetWatchers(strategy)[gameDir], new ErrorEventArgs(new IOException("simulated"))); + + // 1) The workshop watcher is still live — deleting a file there invalidates its cache. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(workshopFile), + () => !FileExists("m.xml".AsSpan(), workshopDir.AsSpan()), + "workshop snapshot to invalidate after m.xml deleted; gameDir Error must not affect it"); + + // 2) The broken root still serves lookups (next call rebuilds snapshot + re-arms watcher). + Assert.True(FileExists("g.xml".AsSpan(), gameDir.AsSpan())); + + // 3) After re-arm, the gameDir watcher tracks changes again. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(gameFile), + () => !FileExists("g.xml".AsSpan(), gameDir.AsSpan()), + "gameDir snapshot to invalidate after g.xml deleted (post-Error rebuild)"); + } + + private LiveVirtualFileExistsStrategy GetActiveLiveStrategy() + { + var field = typeof(PetroglyphFileSystem).GetField("_strategy", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (LiveVirtualFileExistsStrategy)field.GetValue(PgFileSystem)!; + } + + // GetWatchers / InvokeOnWatcherError exist only to *synthesize* an Error event (no portable + // way to make a real FSW fire one). The Error tests themselves assert observable behavior, + // not the watcher dictionary's contents. + private static Dictionary GetWatchers(LiveVirtualFileExistsStrategy strategy) + { + var field = typeof(LiveVirtualFileExistsStrategy).GetField("_watchers", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (Dictionary)field.GetValue(strategy)!; + } + + private static void InvokeOnWatcherError(LiveVirtualFileExistsStrategy strategy, IFileSystemWatcher sender, ErrorEventArgs args) + { + var method = typeof(LiveVirtualFileExistsStrategy).GetMethod("OnWatcherError", BindingFlags.NonPublic | BindingFlags.Instance)!; + method.Invoke(strategy, [sender, args]); + } + + protected static async Task AwaitCacheInvalidationAsync(Action diskAction, Func predicate, string description) + { + diskAction(); + + var ct = TestContext.Current.CancellationToken; + var sw = Stopwatch.StartNew(); + while (true) + { + if (predicate()) + return; + if (sw.Elapsed >= WatcherEventTimeout) + Assert.Fail($"Timed out after {WatcherEventTimeout} waiting for: {description}"); + await Task.Delay(PollInterval, ct); + } + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs new file mode 100644 index 0000000..fcff8fb --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs @@ -0,0 +1,122 @@ +using System; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; +using PG.StarWarsGame.Engine.Utilities; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +/// +/// Tests every -derived strategy must satisfy: +/// per-directory snapshotting, no delegation for in-tree paths, delegation for out-of-tree +/// paths, and missing-directory handling. +/// +public abstract class VirtualFileExistsStrategyBaseTests : FileExistsStrategyTestBase +{ + /// + /// Switch the active strategy on to the strategy under test, with + /// as the fallback for outside-game-directory lookups. + /// + private protected abstract void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying); + + [Fact] + public void FileExists_RepeatedCallsSameDirectory_BothResolveFromSnapshot() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Mods", "Test", "Data", "Xml"); + FileSystem.Directory.CreateDirectory(dataDir); + var foo = FileSystem.Path.Combine(dataDir, "foo.xml"); + var bar = FileSystem.Path.Combine(dataDir, "bar.xml"); + FileSystem.File.WriteAllText(foo, "1"); + FileSystem.File.WriteAllText(bar, "2"); + + var sb1 = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists("MODS/TEST/DATA/XML/FOO.XML".AsSpan(), ref sb1, dir.AsSpan())); + AssertResolvedPath(foo, sb1.ToString()); + + var sb2 = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists("mods/test/data/xml/BAR.XML".AsSpan(), ref sb2, dir.AsSpan())); + AssertResolvedPath(bar, sb2.ToString()); + } + + [Fact] + public void FileExists_MissingDirectoryUnderGameRoot_RemainsMissing() + { + var dir = NewTempDir(); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "Mods", "Test", "Data", "Xml")); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dir, "Mods", "Test", "Data", "Xml", "foo.xml"), "1"); + + Assert.False(FileExists("MODS/TEST/DATA/OTHER/foo.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("mods/test/data/other/bar.xml".AsSpan(), dir.AsSpan())); + } + + [Fact] + public void FileExists_PathOutsideGameDirectory_DelegatesToUnderlying() + { + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "game"); + var outsideDir = FileSystem.Path.Combine(root, "outside"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(outsideDir); + var file = FileSystem.Path.Combine(outsideDir, "FILE.TXT"); + FileSystem.File.WriteAllText(file, "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = file }; + ConfigureStrategy(PgFileSystem, tracking); + + var sb = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists(file.AsSpan(), ref sb, gameDir.AsSpan())); + AssertResolvedPath(file, sb.ToString()); + + Assert.Equal(1, tracking.CallCount); + } + + [Fact] + public void FileExists_PathUnderGameDirectory_DoesNotDelegate() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "foo.xml"), "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public void FileExists_RepeatedLookupInSnapshottedDirectory_DoesNotDelegate() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "foo.xml"), "x"); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "bar.xml"), "y"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.True(FileExists("Data/bar.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/missing.xml".AsSpan(), dir.AsSpan())); + + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public void FileExists_MissingSubdirectoryUnderGameRoot_DoesNotDelegate() + { + var dir = NewTempDir(); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "Data")); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + Assert.False(FileExists("Data/Other/foo.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/Other/bar.xml".AsSpan(), dir.AsSpan())); + + Assert.Equal(0, tracking.CallCount); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs index eb546dd..54e22ef 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs @@ -1,7 +1,6 @@ using System; -using System.IO; using PG.StarWarsGame.Engine.IO; -using PG.StarWarsGame.Engine.Utilities; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; using Xunit; namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; @@ -22,122 +21,29 @@ protected override void ConfigureStrategy(PetroglyphFileSystem fs) => fs.UseVirtualStrategy(); } -public abstract class VirtualFileExistsStrategyTests : FileExistsStrategyTestBase +public abstract class VirtualFileExistsStrategyTests : VirtualFileExistsStrategyBaseTests { - [Fact] - public void FileExists_RepeatedCallsSameDirectory_BothResolveFromSnapshot() - { - var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Mods", "Test", "Data", "Xml"); - Directory.CreateDirectory(dataDir); - var foo = Path.Combine(dataDir, "foo.xml"); - var bar = Path.Combine(dataDir, "bar.xml"); - File.WriteAllText(foo, "1"); - File.WriteAllText(bar, "2"); - - var sb1 = new ValueStringBuilder(); - Assert.True(PgFileSystem.FileExists("MODS/TEST/DATA/XML/FOO.XML".AsSpan(), ref sb1, dir.AsSpan())); - AssertResolvedPath(foo, sb1.ToString()); - - var sb2 = new ValueStringBuilder(); - Assert.True(PgFileSystem.FileExists("mods/test/data/xml/BAR.XML".AsSpan(), ref sb2, dir.AsSpan())); - AssertResolvedPath(bar, sb2.ToString()); - } + private protected override void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying) + => fs.UseVirtualStrategy(underlying); - [Fact] - public void FileExists_MissingDirectoryUnderGameRoot_RemainsMissing() - { - var dir = NewTempDir(); - Directory.CreateDirectory(Path.Combine(dir, "Mods", "Test", "Data", "Xml")); - File.WriteAllText(Path.Combine(dir, "Mods", "Test", "Data", "Xml", "foo.xml"), "1"); - - Assert.False(FileExists("MODS/TEST/DATA/OTHER/foo.xml".AsSpan(), dir.AsSpan())); - Assert.False(FileExists("mods/test/data/other/bar.xml".AsSpan(), dir.AsSpan())); - } + private protected override FileExistsStrategy CreateStrategyForDisposeTest() + => new VirtualFileExistsStrategy(FileSystem, new WineFileExistsStrategy(FileSystem)); [Fact] public void FileExists_AfterFirstResolve_SnapshotServesSubsequentLookups() { var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Data"); - Directory.CreateDirectory(dataDir); - var file = Path.Combine(dataDir, "foo.xml"); - File.WriteAllText(file, "x"); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); Assert.True(FileExists("DATA/foo.xml".AsSpan(), dir.AsSpan())); - File.Delete(file); + FileSystem.File.Delete(file); + // Non-live strategy: snapshot is taken once and serves all subsequent lookups even if + // the file is deleted on disk. The live variant overrides this behavior. Assert.True(FileExists("DATA/foo.xml".AsSpan(), dir.AsSpan())); } - - [Fact] - public void FileExists_PathOutsideGameDirectory_DelegatesToUnderlying() - { - var root = NewTempDir(); - var gameDir = Path.Combine(root, "game"); - var outsideDir = Path.Combine(root, "outside"); - Directory.CreateDirectory(gameDir); - Directory.CreateDirectory(outsideDir); - var file = Path.Combine(outsideDir, "FILE.TXT"); - File.WriteAllText(file, "x"); - - var tracking = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = file }; - PgFileSystem.UseVirtualStrategy(tracking); - - var sb = new ValueStringBuilder(); - Assert.True(PgFileSystem.FileExists(file.AsSpan(), ref sb, gameDir.AsSpan())); - AssertResolvedPath(file, sb.ToString()); - - Assert.Equal(1, tracking.CallCount); - } - - [Fact] - public void FileExists_PathUnderGameDirectory_DoesNotDelegate() - { - var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Data"); - Directory.CreateDirectory(dataDir); - File.WriteAllText(Path.Combine(dataDir, "foo.xml"), "x"); - - var tracking = new TrackingFileExistsStrategy(FileSystem); - PgFileSystem.UseVirtualStrategy(tracking); - - Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); - Assert.Equal(0, tracking.CallCount); - } - - [Fact] - public void FileExists_RepeatedLookupInSnapshottedDirectory_DoesNotDelegate() - { - var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Data"); - Directory.CreateDirectory(dataDir); - File.WriteAllText(Path.Combine(dataDir, "foo.xml"), "x"); - File.WriteAllText(Path.Combine(dataDir, "bar.xml"), "y"); - - var tracking = new TrackingFileExistsStrategy(FileSystem); - PgFileSystem.UseVirtualStrategy(tracking); - - Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); - Assert.True(FileExists("Data/bar.xml".AsSpan(), dir.AsSpan())); - Assert.False(FileExists("Data/missing.xml".AsSpan(), dir.AsSpan())); - - Assert.Equal(0, tracking.CallCount); - } - - [Fact] - public void FileExists_MissingSubdirectoryUnderGameRoot_DoesNotDelegate() - { - var dir = NewTempDir(); - Directory.CreateDirectory(Path.Combine(dir, "Data")); - - var tracking = new TrackingFileExistsStrategy(FileSystem); - PgFileSystem.UseVirtualStrategy(tracking); - - Assert.False(FileExists("Data/Other/foo.xml".AsSpan(), dir.AsSpan())); - Assert.False(FileExists("Data/Other/bar.xml".AsSpan(), dir.AsSpan())); - - Assert.Equal(0, tracking.CallCount); - } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs new file mode 100644 index 0000000..10e64e9 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs @@ -0,0 +1,59 @@ +using System; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.Testing.Attributes; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.Utilities; +using Testably.Abstractions.Testing; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +/// +/// Exercises the snapshot path with the filesystem root (/) as the game directory. +/// Real-disk fixtures cannot create files at / without root privileges, so this uses +/// a Linux-simulated . Only meaningful for the non-live variant — +/// the live variant's binds to the real OS, not the mock. +/// +public sealed class VirtualFileExistsStrategy_RootGameDirectoryTests +{ + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void FileExists_GameDirectoryIsFilesystemRoot_ResolvesFromSnapshot() + { + var mockFs = new MockFileSystem(); + mockFs.File.WriteAllText("/foo.xml", "x"); + + var pgFs = NewPgFs(mockFs); + var tracking = new TrackingFileExistsStrategy(mockFs); + pgFs.UseVirtualStrategy(tracking); + + var sb = new ValueStringBuilder(); + Assert.True(pgFs.FileExists("/foo.xml".AsSpan(), ref sb, "/".AsSpan())); + Assert.Equal("/foo.xml", sb.ToString()); + + // Lookup is under the game directory, so it must resolve from the snapshot, not delegate. + Assert.Equal(0, tracking.CallCount); + } + + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void FileExists_GameDirectoryIsFilesystemRoot_MissingFile_ReportsFalseWithoutDelegating() + { + var mockFs = new MockFileSystem(); + mockFs.File.WriteAllText("/foo.xml", "x"); + + var pgFs = NewPgFs(mockFs); + var tracking = new TrackingFileExistsStrategy(mockFs); + pgFs.UseVirtualStrategy(tracking); + + var sb = new ValueStringBuilder(); + Assert.False(pgFs.FileExists("/missing.xml".AsSpan(), ref sb, "/".AsSpan())); + Assert.Equal(0, tracking.CallCount); + } + + private static PetroglyphFileSystem NewPgFs(IFileSystem fileSystem) + { + var sc = new ServiceCollection(); + sc.AddSingleton(fileSystem); + return new PetroglyphFileSystem(sc.BuildServiceProvider()); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs index 0ee13a7..98d11eb 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; using Xunit; namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; @@ -17,4 +18,11 @@ protected override void ConfigureStrategy(PetroglyphFileSystem fs) Assert.Skip("Windows strategy requires a Windows host."); fs.UseWindowsStrategy(); } + + private protected override FileExistsStrategy CreateStrategyForDisposeTest() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Assert.Skip("Windows strategy requires a Windows host."); + return new WindowsFileExistsStrategy(FileSystem); + } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs index 50e2aff..85a31fc 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs @@ -1,4 +1,5 @@ using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; @@ -6,4 +7,7 @@ public sealed class WineFileExistsStrategyTests : FileExistsStrategyTestBase { protected override void ConfigureStrategy(PetroglyphFileSystem fs) => fs.UseWineStrategy(); + + private protected override FileExistsStrategy CreateStrategyForDisposeTest() + => new WineFileExistsStrategy(FileSystem); } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs index 243e432..d15f10c 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs @@ -2,7 +2,6 @@ using System.IO.Abstractions; using System.Runtime.InteropServices; using AnakinRaW.CommonUtilities.Testing.Attributes; -using PG.StarWarsGame.Engine.Utilities; using Testably.Abstractions; using Xunit; @@ -61,6 +60,13 @@ public void UseVirtualStrategy_DefaultFallback_Resolves() AssertExists(); } + [Fact] + public void UseLiveVirtualStrategy_DefaultFallback_Resolves() + { + PgFileSystem.UseLiveVirtualStrategy(); + AssertExists(); + } + [PlatformSpecificFact(TestPlatformIdentifier.Windows)] public void UseWindowsStrategy_OnWindows_Resolves() { @@ -80,6 +86,12 @@ public void UseVirtualStrategy_WindowsFallback_OnNonWindows_Throws() Assert.Throws(() => PgFileSystem.UseVirtualStrategy(windowsFallback: true)); } + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void UseLiveVirtualStrategy_WindowsFallback_OnNonWindows_Throws() + { + Assert.Throws(() => PgFileSystem.UseLiveVirtualStrategy(windowsFallback: true)); + } + [Fact] public void Switching_BetweenStrategies_LeavesFileSystemUsable() { @@ -89,6 +101,9 @@ public void Switching_BetweenStrategies_LeavesFileSystemUsable() PgFileSystem.UseVirtualStrategy(); AssertExists(); + PgFileSystem.UseLiveVirtualStrategy(); + AssertExists(); + if (IsWindows) { PgFileSystem.UseWindowsStrategy(); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs new file mode 100644 index 0000000..12f835f --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.FileSystem; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +internal sealed class LiveVirtualFileExistsStrategy(IFileSystem fileSystem, FileExistsStrategy underlying) + : VirtualFileExistsStrategyBase(fileSystem, underlying) +{ + private const int WatcherBufferSize = 64 * 1024; + + private readonly object _watchersLock = new(); + private readonly Dictionary _watchers = new(StringComparer.OrdinalIgnoreCase); + private bool _disposed; + + public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) + { + if (!baseDirectory.IsEmpty && FileSystem.Path.IsChildOf(baseDirectory, stringBuilder.AsSpan())) + EnsureWatcher(baseDirectory); + return base.FileExists(baseDirectory, ref stringBuilder); + } + + private void EnsureWatcher(ReadOnlySpan baseDirectory) + { + if (_disposed) + return; + + var rootStr = baseDirectory.ToString(); + if (!FileSystem.Directory.Exists(rootStr)) + return; + + lock (_watchersLock) + { + if (_disposed || _watchers.ContainsKey(rootStr)) + return; + + var watcher = FileSystem.FileSystemWatcher.New(rootStr); + watcher.IncludeSubdirectories = true; + watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName; + watcher.InternalBufferSize = WatcherBufferSize; + + watcher.Created += OnFileEvent; + watcher.Deleted += OnFileEvent; + watcher.Changed += OnFileEvent; + watcher.Renamed += OnFileRenamed; + watcher.Error += OnWatcherError; + + watcher.EnableRaisingEvents = true; + _watchers[rootStr] = watcher; + } + } + + public override void Dispose() + { + IFileSystemWatcher[] watchers; + lock (_watchersLock) + { + if (_disposed) + return; + _disposed = true; + watchers = new IFileSystemWatcher[_watchers.Count]; + _watchers.Values.CopyTo(watchers, 0); + _watchers.Clear(); + } + + foreach (var watcher in watchers) + TearDownWatcher(watcher); + + base.Dispose(); + } + + private void OnFileEvent(object sender, FileSystemEventArgs e) + { + InvalidatePathAndSubtree(e.FullPath); + } + + private void OnFileRenamed(object sender, RenamedEventArgs e) + { + InvalidatePathAndSubtree(e.OldFullPath); + InvalidatePathAndSubtree(e.FullPath); + } + + private void OnWatcherError(object sender, ErrorEventArgs e) + { + IFileSystemWatcher? broken = null; + string? brokenRoot = null; + lock (_watchersLock) + { + if (_disposed) + return; + foreach (var kv in _watchers) + { + if (ReferenceEquals(kv.Value, sender)) + { + broken = kv.Value; + brokenRoot = kv.Key; + break; + } + } + if (broken is null) + return; + _watchers.Remove(brokenRoot!); + } + + ClearCacheUnder(brokenRoot!); + TearDownWatcher(broken); + } + + private void TearDownWatcher(IFileSystemWatcher watcher) + { + watcher.EnableRaisingEvents = false; + watcher.Created -= OnFileEvent; + watcher.Deleted -= OnFileEvent; + watcher.Changed -= OnFileEvent; + watcher.Renamed -= OnFileRenamed; + watcher.Error -= OnWatcherError; + watcher.Dispose(); + } + + private void ClearCacheUnder(string root) + { + Store.TryRemove(root, out _); + var prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? root + : root + Path.DirectorySeparatorChar; + foreach (var key in Store.Keys) + { + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + Store.TryRemove(key, out _); + } + } + + private void InvalidatePathAndSubtree(string fullPath) + { + InvalidateParentOf(fullPath); + InvalidateSubtree(fullPath); + } + + private void InvalidateParentOf(string fullPath) + { + var parent = FileSystem.Path.GetDirectoryName(fullPath); + if (parent is { Length: > 0 }) + Store.TryRemove(parent, out _); + } + + private void InvalidateSubtree(string fullPath) + { + Store.TryRemove(fullPath, out _); + var prefix = fullPath + Path.DirectorySeparatorChar; + foreach (var key in Store.Keys) + { + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + Store.TryRemove(key, out _); + } + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs index f4f077c..dc0fc13 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs @@ -4,17 +4,14 @@ namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; /// /// Immutable snapshot of a single directory's file listing. Files only — no subdirectory recursion. -/// Built once by and never mutated thereafter. /// -internal sealed class VirtualDirectory(string onDiskPath, Dictionary files) +internal sealed class VirtualDirectory(string onDiskPath, IReadOnlyDictionary files) { - /// The directory's path with the on-disk casing. + /// Gets the directory's path with the on-disk casing. public string OnDiskPath { get; } = onDiskPath; /// - /// Filename map. Keys compare case-insensitively (so callers can look up "FOO.XML" against - /// the on-disk "foo.xml") and the value carries the case-preserved on-disk filename used - /// when joining the result back into a full path. + /// Gets the filename map. /// - public Dictionary Files { get; } = files; + public IReadOnlyDictionary Files { get; } = files; } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs index 5583ec7..a47b3f3 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs @@ -1,162 +1,6 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; using System.IO.Abstractions; -using AnakinRaW.CommonUtilities.FileSystem; -using PG.StarWarsGame.Engine.Utilities; namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; internal sealed class VirtualFileExistsStrategy(IFileSystem fileSystem, FileExistsStrategy underlying) - : FileExistsStrategy(fileSystem) -{ - private readonly ConcurrentDictionary _store = - new(StringComparer.OrdinalIgnoreCase); - - public override void Dispose() - { - _store.Clear(); - underlying.Dispose(); - } - - public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) - { - var filePath = stringBuilder.AsSpan(); - - if (!IsUnderBaseDirectory(filePath, baseDirectory)) - return underlying.FileExists(baseDirectory, ref stringBuilder); - - var lastSep = filePath.LastIndexOf(Path.DirectorySeparatorChar); - if (lastSep <= 0) - return underlying.FileExists(baseDirectory, ref stringBuilder); - - var dirSpan = filePath.Slice(0, lastSep); - var fileName = filePath.Slice(lastSep + 1); - if (fileName.IsEmpty) - return underlying.FileExists(baseDirectory, ref stringBuilder); - - var dirKey = dirSpan.ToString(); - if (!_store.TryGetValue(dirKey, out var virtualDir)) - { - virtualDir = TrySnapshot(dirKey); - _store.TryAdd(dirKey, virtualDir); - } - - if (virtualDir is null) - return false; - - if (virtualDir.Files.TryGetValue(fileName.ToString(), out var onDiskName)) - { - stringBuilder.Length = 0; - stringBuilder.Append(virtualDir.OnDiskPath); - if (stringBuilder.Length > 0 && !LowLevelPath.IsDirectorySeparator(stringBuilder[stringBuilder.Length - 1])) - stringBuilder.Append(Path.DirectorySeparatorChar); - stringBuilder.Append(onDiskName); - return true; - } - - return false; - } - - private VirtualDirectory? TrySnapshot(string inputDirPath) - { - var onDiskPath = TryResolveDirectory(inputDirPath); - if (onDiskPath is null) - return null; - - var files = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var entry in FileSystem.Directory.EnumerateFiles(onDiskPath)) - { - var name = FileSystem.Path.GetFileName(entry); - files[name] = name; - } - return new VirtualDirectory(onDiskPath, files); - } - - private string? TryResolveDirectory(string dirPath) - { - if (string.IsNullOrEmpty(dirPath)) - return null; - - if (FileSystem.Directory.Exists(dirPath)) - return dirPath; - - var path = dirPath.AsSpan(); - var rootLen = FileSystem.Path.GetPathRoot(path).Length; - if (rootLen == 0) - return null; - - var currentDir = dirPath.Substring(0, rootLen); - if (!FileSystem.Directory.Exists(currentDir)) - return null; - - var sb = new ValueStringBuilder(stackalloc char[260]); - try - { - sb.Append(currentDir); - - var pos = rootLen; - if (pos < path.Length && path[pos] == Path.DirectorySeparatorChar) - pos++; - - while (pos < path.Length) - { - var rest = path.Slice(pos); - var nextSlash = rest.IndexOf(Path.DirectorySeparatorChar); - var componentEnd = nextSlash >= 0 ? pos + nextSlash : path.Length; - var component = path.Slice(pos, componentEnd - pos); - - if (component.IsEmpty) - { - pos = componentEnd + 1; - continue; - } - - var savedLen = sb.Length; - if (savedLen == 0 || !LowLevelPath.IsDirectorySeparator(sb[savedLen - 1])) - sb.Append(Path.DirectorySeparatorChar); - sb.Append(component); - - var literalPath = sb.AsSpan().ToString(); - if (FileSystem.Directory.Exists(literalPath)) - { - currentDir = literalPath; - pos = componentEnd + 1; - continue; - } - - sb.Length = savedLen; - - var found = false; - foreach (var entry in FileSystem.Directory.EnumerateDirectories(currentDir)) - { - if (FileSystem.Path.GetFileName(entry.AsSpan()).Equals(component, StringComparison.OrdinalIgnoreCase)) - { - sb.Length = 0; - sb.Append(entry); - currentDir = entry; - found = true; - break; - } - } - - if (!found) - return null; - - pos = componentEnd + 1; - } - - return currentDir; - } - finally - { - sb.Dispose(); - } - } - - private bool IsUnderBaseDirectory(ReadOnlySpan path, ReadOnlySpan gameDirectory) - { - return !gameDirectory.IsEmpty && FileSystem.Path.IsChildOf(gameDirectory, path); - } -} + : VirtualFileExistsStrategyBase(fileSystem, underlying); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs new file mode 100644 index 0000000..b6b685b --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.FileSystem; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +internal abstract class VirtualFileExistsStrategyBase(IFileSystem fileSystem, FileExistsStrategy underlying) + : FileExistsStrategy(fileSystem) +{ + protected readonly ConcurrentDictionary Store = + new(StringComparer.OrdinalIgnoreCase); + + protected readonly FileExistsStrategy Underlying = underlying; + + public override void Dispose() + { + Store.Clear(); + Underlying.Dispose(); + } + + public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) + { + var filePath = stringBuilder.AsSpan(); + + if (!IsUnderGameDirectory(filePath, baseDirectory)) + return Underlying.FileExists(baseDirectory, ref stringBuilder); + + var fileName = FileSystem.Path.GetFileName(filePath); + if (fileName.IsEmpty) + return false; + + var dirSpan = FileSystem.Path.GetDirectoryName(filePath); + if (dirSpan.IsEmpty) + return Underlying.FileExists(baseDirectory, ref stringBuilder); + + var dirKey = dirSpan.ToString(); + if (!Store.TryGetValue(dirKey, out var virtualDir)) + { + virtualDir = TrySnapshot(dirKey); + Store.TryAdd(dirKey, virtualDir); + } + + if (virtualDir is null) + return false; + + if (virtualDir.Files.TryGetValue(fileName.ToString(), out var onDiskName)) + { + stringBuilder.Length = 0; + stringBuilder.Append(virtualDir.OnDiskPath); + if (stringBuilder.Length > 0 && !LowLevelPath.IsDirectorySeparator(stringBuilder[stringBuilder.Length - 1])) + stringBuilder.Append(FileSystem.Path.DirectorySeparatorChar); + stringBuilder.Append(onDiskName); + return true; + } + + return false; + } + + private VirtualDirectory? TrySnapshot(string inputDirPath) + { + var onDiskPath = TryResolveDirectory(inputDirPath); + if (onDiskPath is null) + return null; + + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in FileSystem.Directory.EnumerateFiles(onDiskPath)) + { + var name = FileSystem.Path.GetFileName(entry); + files[name] = name; + } + return new VirtualDirectory(onDiskPath, files); + } + + private string? TryResolveDirectory(string dirPath) + { + if (string.IsNullOrEmpty(dirPath)) + return null; + + if (FileSystem.Directory.Exists(dirPath)) + return dirPath; + + var path = dirPath.AsSpan(); + var rootLen = FileSystem.Path.GetPathRoot(path).Length; + if (rootLen == 0) + return null; + + var currentDir = dirPath.Substring(0, rootLen); + if (!FileSystem.Directory.Exists(currentDir)) + return null; + + var sb = new ValueStringBuilder(stackalloc char[260]); + try + { + sb.Append(currentDir); + + var pos = rootLen; + if (pos < path.Length && path[pos] == FileSystem.Path.DirectorySeparatorChar) + pos++; + + while (pos < path.Length) + { + var rest = path.Slice(pos); + var nextSlash = rest.IndexOf(FileSystem.Path.DirectorySeparatorChar); + var componentEnd = nextSlash >= 0 ? pos + nextSlash : path.Length; + var component = path.Slice(pos, componentEnd - pos); + + if (component.IsEmpty) + { + pos = componentEnd + 1; + continue; + } + + var savedLen = sb.Length; + if (savedLen == 0 || !LowLevelPath.IsDirectorySeparator(sb[savedLen - 1])) + sb.Append(FileSystem.Path.DirectorySeparatorChar); + sb.Append(component); + + var literalPath = sb.AsSpan().ToString(); + if (FileSystem.Directory.Exists(literalPath)) + { + currentDir = literalPath; + pos = componentEnd + 1; + continue; + } + + sb.Length = savedLen; + + var found = false; + foreach (var entry in FileSystem.Directory.EnumerateDirectories(currentDir)) + { + if (FileSystem.Path.GetFileName(entry.AsSpan()).Equals(component, StringComparison.OrdinalIgnoreCase)) + { + sb.Length = 0; + sb.Append(entry); + currentDir = entry; + found = true; + break; + } + } + + if (!found) + return null; + + pos = componentEnd + 1; + } + + return currentDir; + } + finally + { + sb.Dispose(); + } + } + + private bool IsUnderGameDirectory(ReadOnlySpan path, ReadOnlySpan gameDirectory) + { + return !gameDirectory.IsEmpty && FileSystem.Path.IsChildOf(gameDirectory, path); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs index c7da143..9136af2 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs @@ -59,6 +59,51 @@ public void UseVirtualStrategy(bool? windowsFallback = null) internal void UseVirtualStrategy(FileExistsStrategy underlying) => SwapStrategy(new VirtualFileExistsStrategy(_underlyingFileSystem, underlying)); + /// + /// Switches the active file-exists strategy to a snapshot-based one that refreshes itself when + /// files are added, removed, or renamed in the game directory. + /// + /// + /// + /// Equivalent to for lookups, but lazily attaches a + /// recursive to every distinct base directory passed + /// to . Each watcher's events invalidate cached directory listings under its + /// root on demand; the next lookup rebuilds the affected snapshot from disk. File + /// content changes are not tracked. + /// + /// + /// Each watcher is created on the first lookup that lands inside its base directory and is torn + /// down when the strategy is replaced or the file system is disposed. If a watcher's internal + /// buffer overflows or the OS otherwise reports an error, only that watcher is removed and only + /// its subtree is evicted from the cache; other roots continue to be tracked. + /// + /// + /// On Linux, each watcher consumes one inotify slot per directory in its subtree (per-user + /// kernel limit, fs.inotify.max_user_watches). Consumers tracking many large trees may + /// need to raise this limit. + /// + /// + /// + /// to delegate outside-game-directory lookups to the Windows + /// strategy; to delegate them to the Wine search + /// engine; to pick the Windows strategy on Windows hosts and the Wine + /// strategy otherwise. + /// + /// + /// is and the host is not Windows. + /// + public void UseLiveVirtualStrategy(bool? windowsFallback = null) + { + var useWindows = windowsFallback ?? RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + FileExistsStrategy fallback = useWindows + ? CreateWindowsStrategy() + : new WineFileExistsStrategy(_underlyingFileSystem); + UseLiveVirtualStrategy(fallback); + } + + internal void UseLiveVirtualStrategy(FileExistsStrategy underlying) + => SwapStrategy(new LiveVirtualFileExistsStrategy(_underlyingFileSystem, underlying)); + private WindowsFileExistsStrategy CreateWindowsStrategy() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) From c3702a9f0901933be6e0a22c6fdfd8981f5e294f Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Mon, 11 May 2026 00:21:23 +0200 Subject: [PATCH 2/8] fix documentation make make project prod ready --- .../IO/PetroglyphFileSystem.CombineJoin.cs | 21 +++++++++++++++++++ .../IO/PetroglyphFileSystem.Strategies.cs | 4 ++-- .../PG.StarWarsGame.Engine.FileSystem.csproj | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs index d9f3cd8..89de4f3 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs @@ -6,6 +6,27 @@ namespace PG.StarWarsGame.Engine.IO; public sealed partial class PetroglyphFileSystem { + /// + /// Combines strings into a path. + /// + /// + /// + /// This method is intended to concatenate individual strings into a single string that represents a file path. + /// However, if an argument other than the first contains a rooted path, any previous path components are ignored, + /// and the returned string begins with that rooted path component. + /// + /// + /// This method supports the directory separator characters ("/") and ("\"). + /// + /// + /// The first path to combine. + /// The second path to combine. + /// + /// The combined paths. If one of the specified paths is a zero-length string, this method returns the other path. + /// If contains an absolute path, this method returns . + /// + /// or is . + /// public string CombinePath(string pathA, string pathB) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs index 9136af2..b6727e7 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs @@ -23,9 +23,9 @@ public sealed partial class PetroglyphFileSystem /// /// /// Selecting this strategy directly is rarely correct. Prefer - /// on non-Windows hosts and + /// on non-Windows hosts and /// on Windows. This method exists primarily to support the search engine used internally by - /// for paths outside the game directory. + /// for paths outside the game directory. /// /// Provides full mediation: every lookup re-walks the path with no caching. /// diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj index 02421b1..07c768d 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj @@ -8,7 +8,7 @@ alamo,petroglyph,glyphx - + true true true From c83c3635d37e39586c7fd6c614b8a2173ab8f7e8 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Thu, 14 May 2026 14:43:15 +0200 Subject: [PATCH 3/8] strategies are not dispoable anymore but can be cleaned. Make LiveVirtualStrategy is faster. --- .../FileExistStrategies/FileExistsStrategy.cs | 4 +- .../LiveVirtualFileExistsStrategy.cs | 53 +++++++++---------- .../VirtualFileExistsStrategyBase.cs | 4 +- .../IO/PetroglyphFileSystem.Strategies.cs | 11 +++- .../IO/PetroglyphFileSystem.cs | 6 +-- 5 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs index 43191cd..3e4a79a 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs @@ -4,11 +4,11 @@ namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; -internal abstract class FileExistsStrategy(IFileSystem fileSystem) : IDisposable +internal abstract class FileExistsStrategy(IFileSystem fileSystem) { protected readonly IFileSystem FileSystem = fileSystem; public abstract bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder); - public virtual void Dispose() { } + internal virtual void Cleanup() { } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs index 12f835f..abef690 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs @@ -1,5 +1,5 @@ using System; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.IO; using System.IO.Abstractions; using AnakinRaW.CommonUtilities.FileSystem; @@ -13,8 +13,7 @@ internal sealed class LiveVirtualFileExistsStrategy(IFileSystem fileSystem, File private const int WatcherBufferSize = 64 * 1024; private readonly object _watchersLock = new(); - private readonly Dictionary _watchers = new(StringComparer.OrdinalIgnoreCase); - private bool _disposed; + private readonly ConcurrentDictionary _watchers = new(StringComparer.OrdinalIgnoreCase); public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) { @@ -23,18 +22,37 @@ public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStrin return base.FileExists(baseDirectory, ref stringBuilder); } + internal override void Cleanup() + { + IFileSystemWatcher[] watchers; + lock (_watchersLock) + { + watchers = new IFileSystemWatcher[_watchers.Count]; + _watchers.Values.CopyTo(watchers, 0); + _watchers.Clear(); + } + + foreach (var watcher in watchers) + TearDownWatcher(watcher); + + base.Cleanup(); + } + private void EnsureWatcher(ReadOnlySpan baseDirectory) { - if (_disposed) + var rootStr = baseDirectory.ToString(); + + // Fast path: already watching this directory — lockless, no OS call. + if (_watchers.ContainsKey(rootStr)) return; - var rootStr = baseDirectory.ToString(); + // Only pay for the Directory.Exists syscall when the watcher might be missing. if (!FileSystem.Directory.Exists(rootStr)) return; lock (_watchersLock) { - if (_disposed || _watchers.ContainsKey(rootStr)) + if (_watchers.ContainsKey(rootStr)) return; var watcher = FileSystem.FileSystemWatcher.New(rootStr); @@ -53,25 +71,6 @@ private void EnsureWatcher(ReadOnlySpan baseDirectory) } } - public override void Dispose() - { - IFileSystemWatcher[] watchers; - lock (_watchersLock) - { - if (_disposed) - return; - _disposed = true; - watchers = new IFileSystemWatcher[_watchers.Count]; - _watchers.Values.CopyTo(watchers, 0); - _watchers.Clear(); - } - - foreach (var watcher in watchers) - TearDownWatcher(watcher); - - base.Dispose(); - } - private void OnFileEvent(object sender, FileSystemEventArgs e) { InvalidatePathAndSubtree(e.FullPath); @@ -89,8 +88,6 @@ private void OnWatcherError(object sender, ErrorEventArgs e) string? brokenRoot = null; lock (_watchersLock) { - if (_disposed) - return; foreach (var kv in _watchers) { if (ReferenceEquals(kv.Value, sender)) @@ -102,7 +99,7 @@ private void OnWatcherError(object sender, ErrorEventArgs e) } if (broken is null) return; - _watchers.Remove(brokenRoot!); + _watchers.TryRemove(brokenRoot!, out _); } ClearCacheUnder(brokenRoot!); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs index b6b685b..3a16e8a 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs @@ -15,10 +15,10 @@ internal abstract class VirtualFileExistsStrategyBase(IFileSystem fileSystem, Fi protected readonly FileExistsStrategy Underlying = underlying; - public override void Dispose() + internal override void Cleanup() { Store.Clear(); - Underlying.Dispose(); + Underlying.Cleanup(); } public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs index b6727e7..7a20b2e 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs @@ -8,6 +8,15 @@ public sealed partial class PetroglyphFileSystem { private FileExistsStrategy _strategy; + internal void CleanupStrategy() => _strategy.Cleanup(); + + private FileExistsStrategy CreateDefaultStrategy() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new WindowsFileExistsStrategy(_underlyingFileSystem) + : new VirtualFileExistsStrategy(_underlyingFileSystem, new WineFileExistsStrategy(_underlyingFileSystem)); + } + /// /// Switches the active file-exists strategy to one that issues a Win32 CreateFileA call per lookup. /// @@ -116,6 +125,6 @@ private void SwapStrategy(FileExistsStrategy next) { var old = _strategy; _strategy = next; - old?.Dispose(); + old.Cleanup(); } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs index ce47d47..5b23112 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs @@ -4,8 +4,6 @@ using System.IO; using System.IO.Abstractions; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using PG.StarWarsGame.Engine.IO.FileExistStrategies; namespace PG.StarWarsGame.Engine.IO; @@ -47,9 +45,7 @@ public PetroglyphFileSystem(IServiceProvider serviceProvider) throw new ArgumentNullException(nameof(serviceProvider)); _underlyingFileSystem = serviceProvider.GetRequiredService(); - _strategy = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? new WindowsFileExistsStrategy(_underlyingFileSystem) - : new VirtualFileExistsStrategy(_underlyingFileSystem, new WineFileExistsStrategy(_underlyingFileSystem)); + _strategy = CreateDefaultStrategy(); } /// From 11a40fa59d12b5d0b22298404a110789ae40f454 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Thu, 14 May 2026 14:52:35 +0200 Subject: [PATCH 4/8] move to auto-property --- .../IO/PetroglyphFileSystem.CombineJoin.cs | 6 ++-- .../IO/PetroglyphFileSystem.Exist.cs | 6 ++-- .../IO/PetroglyphFileSystem.Names.cs | 12 ++++---- .../IO/PetroglyphFileSystem.PathEqual.cs | 6 ++-- .../IO/PetroglyphFileSystem.Strategies.cs | 28 +++++++++++-------- .../IO/PetroglyphFileSystem.cs | 10 +++---- 6 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs index 89de4f3..b89a8ef 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs @@ -30,7 +30,7 @@ public sealed partial class PetroglyphFileSystem public string CombinePath(string pathA, string pathB) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.Combine(pathA, pathB); + return UnderlyingFileSystem.Path.Combine(pathA, pathB); if (pathA == null) throw new ArgumentNullException(nameof(pathA)); @@ -55,7 +55,7 @@ internal void JoinPath(ReadOnlySpan path1, ReadOnlySpan path2, ref V var hasSeparator = IsDirectorySeparator(path1[path1.Length - 1]) || IsDirectorySeparator(path2[0]); if (!hasSeparator) - stringBuilder.Append(_underlyingFileSystem.Path.DirectorySeparatorChar); + stringBuilder.Append(UnderlyingFileSystem.Path.DirectorySeparatorChar); stringBuilder.Append(path2); } @@ -79,6 +79,6 @@ private string JoinInternal(string first, string second) var hasSeparator = IsDirectorySeparator(first[first.Length - 1]) || IsDirectorySeparator(second[0]); return hasSeparator ? string.Concat(first, second) - : string.Concat(first, _underlyingFileSystem.Path.DirectorySeparatorChar, second); + : string.Concat(first, UnderlyingFileSystem.Path.DirectorySeparatorChar, second); } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs index f28f9e1..c1f305e 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs @@ -45,7 +45,7 @@ internal bool FileExists(ReadOnlySpan filePath, ref ValueStringBuilder str NormalizePath(ref stringBuilder); NormalizeDotSegmentsInPlace(ref stringBuilder); - return _strategy.FileExists(baseDirectory, ref stringBuilder); + return Strategy.FileExists(baseDirectory, ref stringBuilder); } internal void NormalizeDotSegmentsInPlace(ref ValueStringBuilder sb) @@ -54,7 +54,7 @@ internal void NormalizeDotSegmentsInPlace(ref ValueStringBuilder sb) if (len == 0) return; - var dirSeparator = _underlyingFileSystem.Path.DirectorySeparatorChar; + var dirSeparator = UnderlyingFileSystem.Path.DirectorySeparatorChar; var rootLen = GetPathRoot(sb.AsSpan()).Length; var writeEnd = rootLen; @@ -106,6 +106,6 @@ internal bool IsPathFullyQualified_Exists(ReadOnlySpan path) // However, this must not happen here, since we are operating on the actual file system. // E.g, \\Data\\Art\\... MUST not be treated as a fully qualified path. // This means, ultimately, we can just delegate to the underlying file system. - return _underlyingFileSystem.Path.IsPathFullyQualified(path); + return UnderlyingFileSystem.Path.IsPathFullyQualified(path); } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs index 1d5648b..f72476a 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs @@ -28,7 +28,7 @@ public sealed partial class PetroglyphFileSystem public string? GetFileName(string? path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.GetFileName(path); + return UnderlyingFileSystem.Path.GetFileName(path); if (path == null) return null; @@ -51,7 +51,7 @@ public sealed partial class PetroglyphFileSystem public ReadOnlySpan GetFileName(ReadOnlySpan path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.GetFileName(path); + return UnderlyingFileSystem.Path.GetFileName(path); var root = GetPathRoot(path).Length; var i = path.LastIndexOfAny(DirectorySeparatorChar, AltDirectorySeparatorChar); @@ -69,7 +69,7 @@ public ReadOnlySpan GetFileName(ReadOnlySpan path) public string? GetFileNameWithoutExtension(string? path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.GetFileNameWithoutExtension(path); + return UnderlyingFileSystem.Path.GetFileNameWithoutExtension(path); if (path == null) return null; @@ -88,7 +88,7 @@ public ReadOnlySpan GetFileName(ReadOnlySpan path) public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.GetFileNameWithoutExtension(path); + return UnderlyingFileSystem.Path.GetFileNameWithoutExtension(path); var fileName = GetFileName(path); var lastPeriod = fileName.LastIndexOf('.'); return lastPeriod < 0 @@ -146,7 +146,7 @@ public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) public string? ChangeExtension(string? path, string? extension) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.ChangeExtension(path, extension); + return UnderlyingFileSystem.Path.ChangeExtension(path, extension); if (path == null) return null; @@ -194,7 +194,7 @@ public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) public ReadOnlySpan GetDirectoryName(ReadOnlySpan path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.GetDirectoryName(path); + return UnderlyingFileSystem.Path.GetDirectoryName(path); if (IsEffectivelyEmpty(path)) return ReadOnlySpan.Empty; diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs index 4afa311..45d0cbb 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs @@ -18,13 +18,13 @@ public sealed partial class PetroglyphFileSystem public bool PathsAreEqual(string pathA, string pathB) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.AreEqual(pathA, pathB); + return UnderlyingFileSystem.Path.AreEqual(pathA, pathB); var normalizedA = PathNormalizer.Normalize(pathA, PGFileSystemDirectorySeparatorNormalizeOptions); var normalizedB = PathNormalizer.Normalize(pathB, PGFileSystemDirectorySeparatorNormalizeOptions); - var fullA = _underlyingFileSystem.Path.GetFullPath(normalizedA); - var fullB = _underlyingFileSystem.Path.GetFullPath(normalizedB); + var fullA = UnderlyingFileSystem.Path.GetFullPath(normalizedA); + var fullB = UnderlyingFileSystem.Path.GetFullPath(normalizedB); return PathsEqual(fullA.AsSpan(), fullB.AsSpan(), Math.Max(fullA.Length, fullB.Length)); } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs index 7a20b2e..5796a74 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs @@ -1,4 +1,6 @@ using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using PG.StarWarsGame.Engine.IO.FileExistStrategies; @@ -6,15 +8,17 @@ namespace PG.StarWarsGame.Engine.IO; public sealed partial class PetroglyphFileSystem { - private FileExistsStrategy _strategy; + [ExcludeFromCodeCoverage] + [EditorBrowsable(EditorBrowsableState.Never)] + internal FileExistsStrategy Strategy { get; private set; } - internal void CleanupStrategy() => _strategy.Cleanup(); + internal void CleanupStrategy() => Strategy.Cleanup(); private FileExistsStrategy CreateDefaultStrategy() { return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? new WindowsFileExistsStrategy(_underlyingFileSystem) - : new VirtualFileExistsStrategy(_underlyingFileSystem, new WineFileExistsStrategy(_underlyingFileSystem)); + ? new WindowsFileExistsStrategy(UnderlyingFileSystem) + : new VirtualFileExistsStrategy(UnderlyingFileSystem, new WineFileExistsStrategy(UnderlyingFileSystem)); } /// @@ -38,7 +42,7 @@ private FileExistsStrategy CreateDefaultStrategy() /// /// Provides full mediation: every lookup re-walks the path with no caching. /// - public void UseWineStrategy() => SwapStrategy(new WineFileExistsStrategy(_underlyingFileSystem)); + public void UseWineStrategy() => SwapStrategy(new WineFileExistsStrategy(UnderlyingFileSystem)); /// /// Switches the active file-exists strategy to an immutable per-directory snapshot scoped to the game directory. @@ -61,12 +65,12 @@ public void UseVirtualStrategy(bool? windowsFallback = null) var useWindows = windowsFallback ?? RuntimeInformation.IsOSPlatform(OSPlatform.Windows); FileExistsStrategy fallback = useWindows ? CreateWindowsStrategy() - : new WineFileExistsStrategy(_underlyingFileSystem); + : new WineFileExistsStrategy(UnderlyingFileSystem); UseVirtualStrategy(fallback); } internal void UseVirtualStrategy(FileExistsStrategy underlying) - => SwapStrategy(new VirtualFileExistsStrategy(_underlyingFileSystem, underlying)); + => SwapStrategy(new VirtualFileExistsStrategy(UnderlyingFileSystem, underlying)); /// /// Switches the active file-exists strategy to a snapshot-based one that refreshes itself when @@ -106,25 +110,25 @@ public void UseLiveVirtualStrategy(bool? windowsFallback = null) var useWindows = windowsFallback ?? RuntimeInformation.IsOSPlatform(OSPlatform.Windows); FileExistsStrategy fallback = useWindows ? CreateWindowsStrategy() - : new WineFileExistsStrategy(_underlyingFileSystem); + : new WineFileExistsStrategy(UnderlyingFileSystem); UseLiveVirtualStrategy(fallback); } internal void UseLiveVirtualStrategy(FileExistsStrategy underlying) - => SwapStrategy(new LiveVirtualFileExistsStrategy(_underlyingFileSystem, underlying)); + => SwapStrategy(new LiveVirtualFileExistsStrategy(UnderlyingFileSystem, underlying)); private WindowsFileExistsStrategy CreateWindowsStrategy() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) throw new PlatformNotSupportedException( "The Windows file-exists strategy relies on Win32 CreateFileA and is only supported on Windows hosts."); - return new WindowsFileExistsStrategy(_underlyingFileSystem); + return new WindowsFileExistsStrategy(UnderlyingFileSystem); } private void SwapStrategy(FileExistsStrategy next) { - var old = _strategy; - _strategy = next; + var old = Strategy; + Strategy = next; old.Cleanup(); } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs index 5b23112..09bcf72 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs @@ -27,12 +27,10 @@ public sealed partial class PetroglyphFileSystem UnifySeparatorKind = DirectorySeparatorKind.System }; - private readonly IFileSystem _underlyingFileSystem; - /// /// Gets the underlying file system abstraction. /// - public IFileSystem UnderlyingFileSystem => _underlyingFileSystem; + public IFileSystem UnderlyingFileSystem { get; } /// /// Initializes a new instance of the class. @@ -43,9 +41,9 @@ public PetroglyphFileSystem(IServiceProvider serviceProvider) { if (serviceProvider == null) throw new ArgumentNullException(nameof(serviceProvider)); - _underlyingFileSystem = serviceProvider.GetRequiredService(); + UnderlyingFileSystem = serviceProvider.GetRequiredService(); - _strategy = CreateDefaultStrategy(); + Strategy = CreateDefaultStrategy(); } /// @@ -65,7 +63,7 @@ public bool HasTrailingDirectorySeparator(ReadOnlySpan path) internal FileSystemStream OpenRead(string filePath) { - return _underlyingFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + return UnderlyingFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); } private static bool IsPathRooted(ReadOnlySpan path) From 58033e5ac83a0514dafbd6af2214d2e6f9b4cf7a Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Thu, 14 May 2026 14:55:49 +0200 Subject: [PATCH 5/8] fix tests --- .../FileExistsStrategyTestBase.cs | 32 +++++++++++---- .../LiveVirtualFileExistsStrategyTests.cs | 39 ++++++++++++++++--- .../VirtualFileExistsStrategyBaseTests.cs | 39 +++++++++++++++++++ .../VirtualFileExistsStrategyTests.cs | 2 +- .../WindowsFileExistsStrategyTests.cs | 2 +- .../WineFileExistsStrategyTests.cs | 2 +- 6 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs index 005cc4a..474cdb3 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs @@ -22,18 +22,36 @@ protected override IFileSystem CreateFileSystem() protected abstract override void ConfigureStrategy(PetroglyphFileSystem fs); /// - /// Constructs a fresh, undisposed instance of the strategy under test, so generic suite - /// tests () can exercise it directly without + /// Constructs a fresh instance of the strategy under test, so generic suite + /// tests () can exercise it directly without /// fighting the 's ownership of the active strategy. /// - private protected abstract FileExistsStrategy CreateStrategyForDisposeTest(); + private protected abstract FileExistsStrategy CreateStrategyForCleanupTest(); [Fact] - public void Dispose_CalledTwice_DoesNotThrow() + public void Cleanup_CalledTwice_DoesNotThrow() { - var strategy = CreateStrategyForDisposeTest(); - strategy.Dispose(); - strategy.Dispose(); + var strategy = CreateStrategyForCleanupTest(); + strategy.Cleanup(); + strategy.Cleanup(); + } + + [Fact] + public void FileExists_AfterCleanup_RemainsUsable() + { + var dir = NewTempDir(); + var file = FileSystem.Path.Combine(dir, "test.txt"); + FileSystem.File.WriteAllText(file, "x"); + + // Warm up the strategy. + Assert.True(FileExists("test.txt".AsSpan(), dir.AsSpan())); + + // Cleanup must not permanently break the strategy. + PgFileSystem.Strategy.Cleanup(); + + // Must still serve correct lookups after Cleanup. + Assert.True(FileExists("test.txt".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("missing.txt".AsSpan(), dir.AsSpan())); } protected virtual void AssertResolvedPath(string expectedOnDiskPath, string actualResult) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs index b47bead..d83bafe 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs @@ -1,5 +1,5 @@ using System; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.Diagnostics; using System.IO; using System.IO.Abstractions; @@ -43,7 +43,7 @@ public abstract class LiveVirtualFileExistsStrategyTests : VirtualFileExistsStra private protected override void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying) => fs.UseLiveVirtualStrategy(underlying); - private protected override FileExistsStrategy CreateStrategyForDisposeTest() + private protected override FileExistsStrategy CreateStrategyForCleanupTest() => new LiveVirtualFileExistsStrategy(FileSystem, new WineFileExistsStrategy(FileSystem)); [Fact] @@ -317,19 +317,46 @@ await AwaitCacheInvalidationAsync( "gameDir snapshot to invalidate after g.xml deleted (post-Error rebuild)"); } + [Fact] + public async Task Cleanup_RemovesWatchersAndClearsCache_WatchersReinstalledOnNextLookup() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); + + // Prime: install a watcher and warm the snapshot. + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + + var strategyBefore = GetActiveLiveStrategy(); + Assert.True(GetWatchers(strategyBefore).ContainsKey(dir)); + + // Cleanup: teardown watchers and clear the snapshot cache. + strategyBefore.Cleanup(); + + // Lookups must still work after Cleanup — the strategy re-snapshots lazily. + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + + // After the first post-cleanup lookup the watcher must be re-armed: disk changes are tracked again. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(file), + () => !FileExists("Data/foo.xml".AsSpan(), dir.AsSpan()), + "snapshot to invalidate after Data/foo.xml deleted (post-Cleanup re-arm)"); + } + private LiveVirtualFileExistsStrategy GetActiveLiveStrategy() { - var field = typeof(PetroglyphFileSystem).GetField("_strategy", BindingFlags.NonPublic | BindingFlags.Instance)!; - return (LiveVirtualFileExistsStrategy)field.GetValue(PgFileSystem)!; + return (LiveVirtualFileExistsStrategy)PgFileSystem.Strategy; } // GetWatchers / InvokeOnWatcherError exist only to *synthesize* an Error event (no portable // way to make a real FSW fire one). The Error tests themselves assert observable behavior, // not the watcher dictionary's contents. - private static Dictionary GetWatchers(LiveVirtualFileExistsStrategy strategy) + private static ConcurrentDictionary GetWatchers(LiveVirtualFileExistsStrategy strategy) { var field = typeof(LiveVirtualFileExistsStrategy).GetField("_watchers", BindingFlags.NonPublic | BindingFlags.Instance)!; - return (Dictionary)field.GetValue(strategy)!; + return (ConcurrentDictionary)field.GetValue(strategy)!; } private static void InvokeOnWatcherError(LiveVirtualFileExistsStrategy strategy, IFileSystemWatcher sender, ErrorEventArgs args) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs index fcff8fb..466d3ad 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Concurrent; +using System.Reflection; using PG.StarWarsGame.Engine.IO; using PG.StarWarsGame.Engine.IO.FileExistStrategies; using PG.StarWarsGame.Engine.Utilities; @@ -119,4 +121,41 @@ public void FileExists_MissingSubdirectoryUnderGameRoot_DoesNotDelegate() Assert.Equal(0, tracking.CallCount); } + + [Fact] + public void Cleanup_ClearsSnapshotCache_FreshSnapshotOnNextLookup() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "foo.xml"), "x"); + + // Prime the snapshot — foo.xml is in cache. + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.NotEmpty(GetSnapshotStore()); + + // Cleanup evicts the snapshot cache. + GetActiveVirtualStrategy().Cleanup(); + Assert.Empty(GetSnapshotStore()); + + // Add a file to disk after cleanup; the post-cleanup re-snapshot must pick it up. + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "bar.xml"), "y"); + + // Post-cleanup: fresh snapshot taken on next lookup — both files visible. + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.True(FileExists("Data/bar.xml".AsSpan(), dir.AsSpan())); + } + + private VirtualFileExistsStrategyBase GetActiveVirtualStrategy() + { + var field = typeof(PetroglyphFileSystem).GetField("_strategy", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (VirtualFileExistsStrategyBase)field.GetValue(PgFileSystem)!; + } + + private ConcurrentDictionary GetSnapshotStore() + { + var strategy = GetActiveVirtualStrategy(); + var field = typeof(VirtualFileExistsStrategyBase).GetField("Store", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (ConcurrentDictionary)field.GetValue(strategy)!; + } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs index 54e22ef..12c4c3f 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs @@ -26,7 +26,7 @@ public abstract class VirtualFileExistsStrategyTests : VirtualFileExistsStrategy private protected override void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying) => fs.UseVirtualStrategy(underlying); - private protected override FileExistsStrategy CreateStrategyForDisposeTest() + private protected override FileExistsStrategy CreateStrategyForCleanupTest() => new VirtualFileExistsStrategy(FileSystem, new WineFileExistsStrategy(FileSystem)); [Fact] diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs index 98d11eb..fbe9ea3 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs @@ -19,7 +19,7 @@ protected override void ConfigureStrategy(PetroglyphFileSystem fs) fs.UseWindowsStrategy(); } - private protected override FileExistsStrategy CreateStrategyForDisposeTest() + private protected override FileExistsStrategy CreateStrategyForCleanupTest() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) Assert.Skip("Windows strategy requires a Windows host."); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs index 85a31fc..c55a8ea 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs @@ -8,6 +8,6 @@ public sealed class WineFileExistsStrategyTests : FileExistsStrategyTestBase protected override void ConfigureStrategy(PetroglyphFileSystem fs) => fs.UseWineStrategy(); - private protected override FileExistsStrategy CreateStrategyForDisposeTest() + private protected override FileExistsStrategy CreateStrategyForCleanupTest() => new WineFileExistsStrategy(FileSystem); } From 560dcd4c9b870e38cdbbe516d3527389c72b42df Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Thu, 14 May 2026 14:57:34 +0200 Subject: [PATCH 6/8] make gameenge disposable and cleanup FS handler --- src/ModVerify/GameVerifyPipeline.cs | 15 ++-- .../PG.StarWarsGame.Engine/GameEngine.cs | 84 ++++++++++++++++--- .../IPetroglyphStarWarsGameEngineService.cs | 9 +- .../IStarWarsGameEngineHandle.cs | 11 +++ .../PetroglyphStarWarsGameEngineService.cs | 9 +- 5 files changed, 104 insertions(+), 24 deletions(-) create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine/IStarWarsGameEngineHandle.cs diff --git a/src/ModVerify/GameVerifyPipeline.cs b/src/ModVerify/GameVerifyPipeline.cs index 4a7e0fb..d0e7d33 100644 --- a/src/ModVerify/GameVerifyPipeline.cs +++ b/src/ModVerify/GameVerifyPipeline.cs @@ -35,6 +35,8 @@ internal sealed class GameVerifyPipeline : StepRunnerPipelineBase Errors => [.._errors]; internal IReadOnlyCollection Verifiers => [.. _verifiers]; @@ -78,18 +80,17 @@ protected override async Task PrepareCoreAsync(CancellationToken token) _verifiers.Clear(); _errors.Clear(); - IStarWarsGameEngine gameEngine; - try { var engineService = ServiceProvider.GetRequiredService(); - gameEngine = await engineService.InitializeAsync( + _gameEngine = await engineService.InitializeAsync( _verificationTarget.Engine, _verificationTarget.Location, _engineErrorReporter, _engineInitializationReporter, false, - CancellationToken.None).ConfigureAwait(false); + configureFileSystem:fs => fs.UseLiveVirtualStrategy(), + cancellationToken: CancellationToken.None).ConfigureAwait(false); } catch (Exception e) { @@ -97,9 +98,9 @@ protected override async Task PrepareCoreAsync(CancellationToken token) throw; } - AddStep(new GameEngineErrorCollector(_engineErrorReporter, gameEngine, _serviceSettings.GameVerifySettings, ServiceProvider)); + AddStep(new GameEngineErrorCollector(_engineErrorReporter, _gameEngine, _serviceSettings.GameVerifySettings, ServiceProvider)); - foreach (var gameVerificationStep in CreateVerifiers(gameEngine)) + foreach (var gameVerificationStep in CreateVerifiers(_gameEngine)) AddStep(gameVerificationStep); } @@ -147,6 +148,8 @@ protected override void DisposeResources() _engineErrorReporter.Clear(); _aggregatedVerifyProgressReporter?.Dispose(); _aggregatedVerifyProgressReporter = null; + _gameEngine?.Dispose(); + _gameEngine = null; } private void AddStep(GameVerifier verifier) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameEngine.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameEngine.cs index 8188117..37d17eb 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameEngine.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameEngine.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Threading; using PG.StarWarsGame.Engine.Audio.Sfx; using PG.StarWarsGame.Engine.CommandBar; using PG.StarWarsGame.Engine.GameConstants; @@ -11,25 +13,83 @@ namespace PG.StarWarsGame.Engine; -internal sealed class GameEngine : IStarWarsGameEngine +internal sealed class GameEngine : IStarWarsGameEngineHandle { + private int _disposed; + + private PetroglyphFileSystem? _pgFileSystem; + public required GameEngineType EngineType { get; init; } - public required IPGRender PGRender { get; init; } + public required IPGRender PGRender + { + get { ThrowIfDisposed(); return field; } + init; + } + + public required IFontManager FontManager + { + get { ThrowIfDisposed(); return field; } + init; + } + + public required ICommandBarGameManager CommandBar + { + get { ThrowIfDisposed(); return field; } + init; + } - public required IFontManager FontManager { get; init; } + public required IGameRepository GameRepository + { + get { ThrowIfDisposed(); return field; } + init + { + field = value; + _pgFileSystem = value.PGFileSystem; + } + } - public required ICommandBarGameManager CommandBar { get; init; } + public required IGameConstants GameConstants + { + get { ThrowIfDisposed(); return field; } + init; + } - public required IGameRepository GameRepository { get; init; } + public required IGuiDialogManager GuiDialogManager + { + get { ThrowIfDisposed(); return field; } + init; + } - public required IGameConstants GameConstants { get; init; } + public required IGameObjectTypeGameManager GameObjectTypeManager + { + get { ThrowIfDisposed(); return field; } + init; + } - public required IGuiDialogManager GuiDialogManager { get; init; } + public required ISfxEventGameManager SfxGameManager + { + get { ThrowIfDisposed(); return field; } + init; + } - public required IGameObjectTypeGameManager GameObjectTypeManager { get; init; } + public required IEnumerable InstalledLanguages + { + get { ThrowIfDisposed(); return field; } + init; + } - public required ISfxEventGameManager SfxGameManager { get; init; } + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + _pgFileSystem?.CleanupStrategy(); + _pgFileSystem = null; + } - public required IEnumerable InstalledLanguages { get; init; } -} \ No newline at end of file + private void ThrowIfDisposed() + { + if (_disposed != 0) + throw new ObjectDisposedException(nameof(GameEngine)); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IPetroglyphStarWarsGameEngineService.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IPetroglyphStarWarsGameEngineService.cs index 80aaa61..ddb5476 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IPetroglyphStarWarsGameEngineService.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IPetroglyphStarWarsGameEngineService.cs @@ -1,16 +1,19 @@ -using System.Threading; +using System; +using System.Threading; using System.Threading.Tasks; using PG.StarWarsGame.Engine.ErrorReporting; +using PG.StarWarsGame.Engine.IO; namespace PG.StarWarsGame.Engine; public interface IPetroglyphStarWarsGameEngineService { - public Task InitializeAsync( + public Task InitializeAsync( GameEngineType engineType, GameLocations gameLocations, IGameEngineErrorReporter? errorReporter = null, - IGameEngineInitializationReporter? initReporter = null, + IGameEngineInitializationReporter? initReporter = null, bool cancelOnInitializationError = false, + Action? configureFileSystem = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IStarWarsGameEngineHandle.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IStarWarsGameEngineHandle.cs new file mode 100644 index 0000000..b183342 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IStarWarsGameEngineHandle.cs @@ -0,0 +1,11 @@ +using System; + +namespace PG.StarWarsGame.Engine; + +/// +/// An owned reference to a that controls its lifetime. +/// Disposing this handle releases all resources held by the engine. +/// +public interface IStarWarsGameEngineHandle : IStarWarsGameEngine, IDisposable +{ +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphStarWarsGameEngineService.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphStarWarsGameEngineService.cs index 42a2416..e8cc0b2 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphStarWarsGameEngineService.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphStarWarsGameEngineService.cs @@ -23,12 +23,13 @@ internal sealed class PetroglyphStarWarsGameEngineService(IServiceProvider servi private readonly ILogger? _logger = serviceProvider.GetService() ?.CreateLogger(typeof(PetroglyphStarWarsGameEngineService)); - public async Task InitializeAsync( + public async Task InitializeAsync( GameEngineType engineType, GameLocations gameLocations, IGameEngineErrorReporter? errorReporter = null, IGameEngineInitializationReporter? initReporter = null, bool cancelOnInitializationError = false, + Action? configureFileSystem = null, CancellationToken cancellationToken = default) { @@ -39,7 +40,7 @@ public async Task InitializeAsync( try { - return await InitializeEngineAsync(engineType, gameLocations, errorListenerWrapper, initReporter, cts.Token) + return await InitializeEngineAsync(engineType, gameLocations, errorListenerWrapper, initReporter, configureFileSystem, cts.Token) .ConfigureAwait(false); } finally @@ -57,11 +58,12 @@ void OnInitializationError(object sender, InitializationError e) } } - private async Task InitializeEngineAsync( + private async Task InitializeEngineAsync( GameEngineType engineType, GameLocations gameLocations, GameEngineErrorReporterWrapper errorReporter, IGameEngineInitializationReporter? initReporter, + Action? configureFileSystem, CancellationToken token) { try @@ -71,6 +73,7 @@ private async Task InitializeEngineAsync( var repoFactory = _serviceProvider.GetRequiredService(); var repository = repoFactory.Create(engineType, gameLocations, errorReporter); + configureFileSystem?.Invoke(repository.PGFileSystem); var pgRender = new PGRender(repository, errorReporter, serviceProvider); From b78ae9b80d3430305b26b05fc441293e34f27a9f Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Thu, 14 May 2026 15:05:21 +0200 Subject: [PATCH 7/8] use different FS strategies on verify and create baseline --- src/ModVerify.CliApp/Settings/SettingsBuilder.cs | 5 +++-- src/ModVerify/GameVerifyPipeline.cs | 9 +++++++-- .../Settings/VerifierServiceSettings.cs | 2 ++ .../ModVerify.CliApp.Test/SettingsBuilderTest.cs | 16 ++++++++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/ModVerify.CliApp/Settings/SettingsBuilder.cs b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs index fe31595..82ac451 100644 --- a/src/ModVerify.CliApp/Settings/SettingsBuilder.cs +++ b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs @@ -36,13 +36,14 @@ private AppVerifySettings BuildFromVerifyVerb(VerifyVerbOption verifyOptions) ParallelVerifiers = verifyOptions.Parallel ? 4 : 1, VerifiersProvider = new DefaultGameVerifiersProvider(), FailFastSettings = failFastSetting, + UseLiveVirtualFileSystem = true, GameVerifySettings = new GameVerifySettings { IgnoreAsserts = verifyOptions.IgnoreAsserts, - ThrowsOnMinimumSeverity = failFastSetting.IsFailFast + ThrowsOnMinimumSeverity = failFastSetting.IsFailFast ? failFastSetting.MinumumSeverity // The app shall not make a specific verifier throw, but it should always run to completion. - : null + : null } }, AppFailsOnMinimumSeverity = verifyOptions.MinimumFailureSeverity, diff --git a/src/ModVerify/GameVerifyPipeline.cs b/src/ModVerify/GameVerifyPipeline.cs index d0e7d33..6987291 100644 --- a/src/ModVerify/GameVerifyPipeline.cs +++ b/src/ModVerify/GameVerifyPipeline.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using PG.StarWarsGame.Engine; +using PG.StarWarsGame.Engine.IO; namespace AET.ModVerify; @@ -83,14 +84,18 @@ protected override async Task PrepareCoreAsync(CancellationToken token) try { var engineService = ServiceProvider.GetRequiredService(); + Action? configureFs = _serviceSettings.UseLiveVirtualFileSystem + ? static fs => fs.UseLiveVirtualStrategy() + : null; + _gameEngine = await engineService.InitializeAsync( _verificationTarget.Engine, _verificationTarget.Location, _engineErrorReporter, _engineInitializationReporter, false, - configureFileSystem:fs => fs.UseLiveVirtualStrategy(), - cancellationToken: CancellationToken.None).ConfigureAwait(false); + configureFs, + CancellationToken.None).ConfigureAwait(false); } catch (Exception e) { diff --git a/src/ModVerify/Settings/VerifierServiceSettings.cs b/src/ModVerify/Settings/VerifierServiceSettings.cs index 762806d..0feb6ac 100644 --- a/src/ModVerify/Settings/VerifierServiceSettings.cs +++ b/src/ModVerify/Settings/VerifierServiceSettings.cs @@ -9,4 +9,6 @@ public sealed class VerifierServiceSettings public FailFastSetting FailFastSettings { get; init; } = FailFastSetting.NoFailFast; public int ParallelVerifiers { get; init; } = 4; + + public bool UseLiveVirtualFileSystem { get; init; } = false; } \ No newline at end of file diff --git a/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs index 429567b..8ee8d50 100644 --- a/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs +++ b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs @@ -107,4 +107,20 @@ public void BuildSettings_UseDefaultBaseline_Alone_DoesNotThrow() var settings = _builder.BuildSettings(options); Assert.NotNull(settings); } + + [Fact] + public void BuildSettings_VerifyVerb_UsesLiveVirtualFileSystem() + { + var options = new VerifyVerbOption { TargetPath = "myPath" }; + var settings = (AppVerifySettings)_builder.BuildSettings(options); + Assert.True(settings.VerifierServiceSettings.UseLiveVirtualFileSystem); + } + + [Fact] + public void BuildSettings_CreateBaselineVerb_DoesNotUseLiveVirtualFileSystem() + { + var options = new CreateBaselineVerbOption { TargetPath = "myPath", OutputFile = "out.json" }; + var settings = (AppBaselineSettings)_builder.BuildSettings(options); + Assert.False(settings.VerifierServiceSettings.UseLiveVirtualFileSystem); + } } From 35701a466df10d324ac86592fbceae30dad938eb Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Thu, 14 May 2026 15:15:02 +0200 Subject: [PATCH 8/8] fix tests --- .../VirtualFileExistsStrategyBaseTests.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs index 466d3ad..19f9cc8 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Reflection; using PG.StarWarsGame.Engine.IO; using PG.StarWarsGame.Engine.IO.FileExistStrategies; using PG.StarWarsGame.Engine.Utilities; @@ -147,15 +146,12 @@ public void Cleanup_ClearsSnapshotCache_FreshSnapshotOnNextLookup() } private VirtualFileExistsStrategyBase GetActiveVirtualStrategy() - { - var field = typeof(PetroglyphFileSystem).GetField("_strategy", BindingFlags.NonPublic | BindingFlags.Instance)!; - return (VirtualFileExistsStrategyBase)field.GetValue(PgFileSystem)!; - } + => (VirtualFileExistsStrategyBase)PgFileSystem.Strategy; private ConcurrentDictionary GetSnapshotStore() { var strategy = GetActiveVirtualStrategy(); - var field = typeof(VirtualFileExistsStrategyBase).GetField("Store", BindingFlags.NonPublic | BindingFlags.Instance)!; + var field = typeof(VirtualFileExistsStrategyBase).GetField("Store", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; return (ConcurrentDictionary)field.GetValue(strategy)!; } }