diff --git a/src/ModVerify.CliApp/Settings/SettingsBuilder.cs b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs index 5a7fb4e..afa188e 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 35e6e90..4c8bbcb 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; @@ -35,6 +36,8 @@ internal sealed class GameVerifyPipeline : StepRunnerPipelineBase _errors; internal IReadOnlyCollection Verifiers => [.. _verifiers]; @@ -78,17 +81,20 @@ protected override async Task PrepareCoreAsync(CancellationToken token) _verifiers.Clear(); _errors = VerificationErrors.Empty; - IStarWarsGameEngine gameEngine; - try { var engineService = ServiceProvider.GetRequiredService(); - gameEngine = await engineService.InitializeAsync( + Action? configureFs = _serviceSettings.UseLiveVirtualFileSystem + ? static fs => fs.UseLiveVirtualStrategy() + : null; + + _gameEngine = await engineService.InitializeAsync( _verificationTarget.Engine, _verificationTarget.Location, _engineErrorReporter, _engineInitializationReporter, false, + configureFs, CancellationToken.None).ConfigureAwait(false); } catch (Exception e) @@ -97,9 +103,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); } @@ -153,6 +159,8 @@ protected override void DisposeResources() _engineErrorReporter.Clear(); _aggregatedVerifyProgressReporter?.Dispose(); _aggregatedVerifyProgressReporter = null; + _gameEngine?.Dispose(); + _gameEngine = null; } private void AddStep(GameVerifier verifier) 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/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..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 @@ -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,39 @@ protected override IFileSystem CreateFileSystem() protected abstract override void ConfigureStrategy(PetroglyphFileSystem fs); + /// + /// 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 CreateStrategyForCleanupTest(); + + [Fact] + public void Cleanup_CalledTwice_DoesNotThrow() + { + 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) { var expected = expectedOnDiskPath.Replace('\\', FileSystem.Path.DirectorySeparatorChar).Replace('/', FileSystem.Path.DirectorySeparatorChar); @@ -53,10 +87,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..d83bafe --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs @@ -0,0 +1,383 @@ +using System; +using System.Collections.Concurrent; +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 CreateStrategyForCleanupTest() + => 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)"); + } + + [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() + { + 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 ConcurrentDictionary GetWatchers(LiveVirtualFileExistsStrategy strategy) + { + var field = typeof(LiveVirtualFileExistsStrategy).GetField("_watchers", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (ConcurrentDictionary)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..19f9cc8 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Concurrent; +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); + } + + [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() + => (VirtualFileExistsStrategyBase)PgFileSystem.Strategy; + + private ConcurrentDictionary GetSnapshotStore() + { + var strategy = GetActiveVirtualStrategy(); + var field = typeof(VirtualFileExistsStrategyBase).GetField("Store", System.Reflection.BindingFlags.NonPublic | System.Reflection.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 eb546dd..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 @@ -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 CreateStrategyForCleanupTest() + => 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..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 @@ -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 CreateStrategyForCleanupTest() + { + 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..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 @@ -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 CreateStrategyForCleanupTest() + => 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/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 new file mode 100644 index 0000000..abef690 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Concurrent; +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 ConcurrentDictionary _watchers = new(StringComparer.OrdinalIgnoreCase); + + 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); + } + + 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) + { + var rootStr = baseDirectory.ToString(); + + // Fast path: already watching this directory — lockless, no OS call. + if (_watchers.ContainsKey(rootStr)) + return; + + // Only pay for the Directory.Exists syscall when the watcher might be missing. + if (!FileSystem.Directory.Exists(rootStr)) + return; + + lock (_watchersLock) + { + if (_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; + } + } + + 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) + { + foreach (var kv in _watchers) + { + if (ReferenceEquals(kv.Value, sender)) + { + broken = kv.Value; + brokenRoot = kv.Key; + break; + } + } + if (broken is null) + return; + _watchers.TryRemove(brokenRoot!, out _); + } + + 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..3a16e8a --- /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; + + internal override void Cleanup() + { + Store.Clear(); + Underlying.Cleanup(); + } + + 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.CombineJoin.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs index d9f3cd8..b89a8ef 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs @@ -6,10 +6,31 @@ 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)) - return _underlyingFileSystem.Path.Combine(pathA, pathB); + return UnderlyingFileSystem.Path.Combine(pathA, pathB); if (pathA == null) throw new ArgumentNullException(nameof(pathA)); @@ -34,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); } @@ -58,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 c7da143..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,7 +8,18 @@ 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(); + + 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. @@ -23,13 +36,13 @@ 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. /// - 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. @@ -52,25 +65,70 @@ 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 + /// 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)) 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; - old?.Dispose(); + 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 ce47d47..09bcf72 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; @@ -29,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. @@ -45,11 +41,9 @@ public PetroglyphFileSystem(IServiceProvider serviceProvider) { if (serviceProvider == null) throw new ArgumentNullException(nameof(serviceProvider)); - _underlyingFileSystem = serviceProvider.GetRequiredService(); + UnderlyingFileSystem = serviceProvider.GetRequiredService(); - _strategy = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? new WindowsFileExistsStrategy(_underlyingFileSystem) - : new VirtualFileExistsStrategy(_underlyingFileSystem, new WineFileExistsStrategy(_underlyingFileSystem)); + Strategy = CreateDefaultStrategy(); } /// @@ -69,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) 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 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); diff --git a/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs index d79c7b0..f83ffea 100644 --- a/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs +++ b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs @@ -132,4 +132,20 @@ public void BuildSettings_Baselines_SplitsByPathSeparator() [FileSystem.Path.GetFullPath("first.json"), FileSystem.Path.GetFullPath("second.json")], verifySettings.ReportSettings.BaselinePaths); } + + [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); + } }