using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using NUnit.Framework; using ShrinkDataSaver; using ShrinkEventBus; using ShrinkSDK.Runtime; namespace Shrink.Inspect.RuntimeTests; [TestFixture, NonParallelizable] public sealed class DeferredStorageTests { private IShrinkMainThreadDispatcher _previous = null!; [SetUp] public void SetUp() => _previous = ShrinkEventBusRuntime.MainThreadDispatcher; [TearDown] public void TearDown() => ShrinkEventBusRuntime.ConfigureMainThreadDispatcher(_previous); [Test] public void DeferredUsesSeparateDispatcherAndNextPumpForRecursion() { var dispatcher = new Dispatcher(); ShrinkEventBusRuntime.ConfigureMainThreadDispatcher(dispatcher); using var bus = EventBus.CreateBus(ShrinkBusKey.World(Guid.NewGuid().ToString()), new ShrinkBusOptions { Scheduler = ShrinkBusSchedulerKind.MainThread }); var seen = new List(); using var observer = EventBus.ObserveDiagnostics(trace => { if (trace.BusKey != bus.Key) return; seen.Add(1); if (seen.Count == 1) EventBus.PostDeferred(bus, new Evt()); }); Assert.That(EventBus.PostDeferred(bus, new Evt()).Accepted, Is.True); Assert.That(seen, Is.Empty); dispatcher.Pump(); Assert.That(seen.Count, Is.EqualTo(1)); dispatcher.Pump(); Assert.That(seen.Count, Is.EqualTo(2)); Assert.That(dispatcher.InlineCalls, Is.Zero); EventBus.RemoveBus(bus.Key); } [Test] public void OverflowDuringCallbackDoesNotExecuteNewEventsInSamePump() { var dispatcher = new Dispatcher(); ShrinkEventBusRuntime.ConfigureMainThreadDispatcher(dispatcher); using var bus = EventBus.CreateBus(ShrinkBusKey.World(Guid.NewGuid().ToString()), new ShrinkBusOptions { Scheduler = ShrinkBusSchedulerKind.MainThread, QueueCapacity = 2, OverflowPolicy = ShrinkQueueOverflowPolicy.DropOldest }); var count = 0; using var observer = EventBus.ObserveDiagnostics(trace => { if (trace.BusKey != bus.Key) return; if (++count == 1) for (var i = 0; i < 3; i++) EventBus.PostDeferred(bus, new Evt()); }); EventBus.PostDeferred(bus, new Evt()); EventBus.PostDeferred(bus, new Evt()); dispatcher.Pump(); Assert.That(count, Is.EqualTo(2)); dispatcher.Pump(); Assert.That(count, Is.EqualTo(4)); EventBus.RemoveBus(bus.Key); } [Test] public void RejectedDispatcherDoesNotConsumeCapacity() { var dispatcher = new Dispatcher { Reject = true }; ShrinkEventBusRuntime.ConfigureMainThreadDispatcher(dispatcher); using var bus = EventBus.CreateBus(ShrinkBusKey.World(Guid.NewGuid().ToString()), new ShrinkBusOptions { Scheduler = ShrinkBusSchedulerKind.MainThread, QueueCapacity = 1 }); Assert.That(EventBus.PostDeferred(bus, new Evt()).Failure, Is.EqualTo(ShrinkPostFailure.SchedulerRejected)); dispatcher.Reject = false; Assert.That(EventBus.PostDeferred(bus, new Evt()).Accepted, Is.True); dispatcher.Pump(); EventBus.RemoveBus(bus.Key); } [Test] public void NormalAndDeferredShareCapacityAndDisposeDropsPending() { var dispatcher = new Dispatcher { IsMainThread = false }; ShrinkEventBusRuntime.ConfigureMainThreadDispatcher(dispatcher); using var bus = EventBus.CreateBus(ShrinkBusKey.World(Guid.NewGuid().ToString()), new ShrinkBusOptions { Scheduler = ShrinkBusSchedulerKind.MainThread, QueueCapacity = 1 }); Assert.That(bus.Post(new Evt()).Accepted, Is.True); Assert.That(EventBus.PostDeferred(bus, new Evt()).Failure, Is.EqualTo(ShrinkPostFailure.QueueFull)); var dispatched = 0; using var observation = EventBus.ObserveDiagnostics(trace => { if (trace.BusKey == bus.Key) dispatched++; }); EventBus.RemoveBus(bus.Key); dispatcher.Pump(); Assert.That(dispatched, Is.Zero); } [Test] public async Task RenameRecoveryNeverPromotesPartialTempAndKeepsBackups() { var root = Path.Combine(Path.GetTempPath(), "shrink-storage-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try { Assert.Throws(() => new LocalStorageProvider(root, LocalStorageCommitStrategy.RenameOnly)); var storage = new LocalStorageProvider(root, LocalStorageCommitStrategy.RenameOnly, (source, target) => File.Move(source, target, true)); File.WriteAllText(Path.Combine(root, "save.tmp"), "partial"); Assert.That(await storage.ExistsAsync("save"), Is.False); await storage.WriteAsync("save", new byte[] { 1 }); await storage.WriteAsync("save", new byte[] { 2 }); await storage.WriteAsync("save", new byte[] { 3 }); Assert.That(await storage.ReadAsync("save.bak1"), Is.EqualTo(new byte[] { 2 })); Assert.That(await storage.ReadAsync("save.bak2"), Is.EqualTo(new byte[] { 1 })); File.Delete(Path.Combine(root, "save")); File.WriteAllText(Path.Combine(root, "save.tmp"), "partial"); Assert.That(await storage.ExistsAsync("save"), Is.True); Assert.That(await storage.ReadAsync("save"), Is.EqualTo(new byte[] { 2 })); await storage.DeleteAsync("save"); Assert.That(await storage.ExistsAsync("save"), Is.False, "Deleted saves must not recover from backups."); } finally { Directory.Delete(root, true); } } [Test] public async Task DeleteReportsLockedFileInsteadOfClaimingSuccess() { if (!OperatingSystem.IsWindows()) Assert.Ignore("Windows file sharing semantics."); var root = Path.Combine(Path.GetTempPath(), "shrink-delete-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try { var storage = new LocalStorageProvider(root); await storage.WriteAsync("save", new byte[] { 1 }); using var locked = new FileStream(Path.Combine(root, "save"), FileMode.Open, FileAccess.Read, FileShare.None); Assert.ThrowsAsync(async () => await storage.DeleteAsync("save")); } finally { Directory.Delete(root, true); } } private readonly struct Evt : IShrinkEvent { } private sealed class Dispatcher : IShrinkMainThreadDispatcher, IShrinkDeferredMainThreadDispatcher { private readonly Queue _pending = new(); public bool IsMainThread { get; set; } = true; public bool Reject { get; set; } public int InlineCalls { get; private set; } public bool TryPost(Action action) { InlineCalls++; action(); return true; } public bool TryPostDeferred(Action action) { if (Reject) return false; _pending.Enqueue(action); return true; } public void Pump() { var count = _pending.Count; while (count-- > 0) _pending.Dequeue()(); } } }