diff --git a/.editorconfig b/.editorconfig index 748e2e27..dbee2d28 100644 --- a/.editorconfig +++ b/.editorconfig @@ -254,6 +254,8 @@ csharp_style_prefer_extended_property_pattern = true:suggestion csharp_style_var_for_built_in_types = false:silent csharp_style_var_when_type_is_apparent = false:silent csharp_style_var_elsewhere = false:silent +dotnet_diagnostic.IDE0008.severity = none +dotnet_diagnostic.IDE0011.severity = none dotnet_diagnostic.IDE0051.severity = warning dotnet_diagnostic.IDE0052.severity = warning dotnet_diagnostic.IDE0064.severity = warning @@ -275,6 +277,9 @@ dotnet_diagnostic.IDE0004.severity = warning dotnet_diagnostic.IDE0005.severity = warning dotnet_diagnostic.IDE0010.severity = none dotnet_diagnostic.IDE0016.severity = warning +dotnet_diagnostic.IDE0022.severity = none +dotnet_diagnostic.IDE0023.severity = none +dotnet_diagnostic.IDE0024.severity = none dotnet_diagnostic.IDE0028.severity = warning dotnet_diagnostic.IDE0029.severity = warning dotnet_diagnostic.IDE0030.severity = warning @@ -290,8 +295,9 @@ dotnet_diagnostic.IDE0071.severity = warning dotnet_diagnostic.IDE0082.severity = warning dotnet_diagnostic.IDE0072.severity = none dotnet_diagnostic.IDE0120.severity = warning -dotnet_diagnostic.IDE0161.severity = warning dotnet_diagnostic.IDE0130.severity = none +dotnet_diagnostic.IDE0160.severity = none +dotnet_diagnostic.IDE0161.severity = warning dotnet_diagnostic.IDE0180.severity = warning dotnet_diagnostic.IDE0230.severity = warning dotnet_diagnostic.IDE0270.severity = warning diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 11d64c52..e0cdfcef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,8 +50,10 @@ jobs: - name: Debug Build run: dotnet build --configuration Debug --no-restore --verbosity normal ${{ inputs.warningsAsErrors && '-warnaserror' || '' }} - name: Debug Tests - run: dotnet test --configuration Debug --no-restore --verbosity normal + timeout-minutes: 10 + run: dotnet test --configuration Debug --no-restore --no-build --verbosity normal -- NUnit.DebugExecution=true NUnit.DebugDiscovery=true - name: Release Build run: dotnet build --configuration Release --no-restore --verbosity normal ${{ inputs.warningsAsErrors && '-warnaserror' || '' }} - name: Release Tests - run: dotnet test --configuration Release --no-restore --verbosity normal + timeout-minutes: 10 + run: dotnet test --configuration Release --no-restore --no-build --verbosity normal diff --git a/zzre.core.tests/SynchronizedTestFixture.cs b/zzre.core.tests/SynchronizedTestFixture.cs new file mode 100644 index 00000000..15344a68 --- /dev/null +++ b/zzre.core.tests/SynchronizedTestFixture.cs @@ -0,0 +1,38 @@ +using System; +using System.Reflection; +using System.Threading; +using NUnit.Framework; + +// as workaround to STA not being supported on Linux +// from: https://github.com/nunit/nunit/issues/4110 + +public abstract class SynchronizedTestFixture +{ + private SynchronizationContext? _previousContext; + private SynchronizationContext? _ourContext; + + [SetUp] + public void SynchronizedSetup() + { + _previousContext = SynchronizationContext.Current; + _ourContext = CreateNUnitSynchronizationContext(); + SynchronizationContext.SetSynchronizationContext(_ourContext); + } + + [TearDown] + public void SynchronizedTeardown() + { + SynchronizationContext.SetSynchronizationContext(_previousContext); + } + + private static SynchronizationContext CreateNUnitSynchronizationContext() + { + Type type = typeof(Assert).Assembly.GetType("NUnit.Framework.Internal.SingleThreadedTestSynchronizationContext")!; + + return (SynchronizationContext)Activator.CreateInstance(type, + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance, + null, + [new TimeSpan(TimeSpan.TicksPerSecond)], + null)!; + } +} diff --git a/zzre.core.tests/TestAssetRegistry.cs b/zzre.core.tests/TestAssetRegistry.cs new file mode 100644 index 00000000..4b1014fa --- /dev/null +++ b/zzre.core.tests/TestAssetRegistry.cs @@ -0,0 +1,1314 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using NUnit.Framework.Constraints; +using static zzre.IAssetRegistryDebug; + +namespace zzre.tests; + +[TestFixture(TaskContinuationOptions.None)] +[TestFixture(TaskContinuationOptions.RunContinuationsAsynchronously)] +[CancelAfter(3000)] +public class TestAssetRegistry : SynchronizedTestFixture +{ + class SynchronousAsset : Asset + { + public SynchronousAsset(IAssetRegistry registry, Guid id) : base(registry, id) + { + } + + protected override IEnumerable Load() + { + return NoSecondaryAssets; + } + + protected override void Unload() + { + } + } + + class SynchronousGlobalAsset : SynchronousAsset + { + public SynchronousGlobalAsset(IAssetRegistry registry, Guid id, Info info) : base(registry, id) + { + InfoID = info.id; + } + + public readonly record struct Info(int id); + public int InfoID { get; } + } + + class SynchronousContextAsset : SynchronousAsset + { + public SynchronousContextAsset(IAssetRegistry registry, Guid id, Info info) : base(registry, id) + { + InfoID = info.id; + } + + public readonly record struct Info(int id); + public int InfoID { get; } + } + + class SynchronousSingleUsageAsset : SynchronousAsset + { + public SynchronousSingleUsageAsset(IAssetRegistry registry, Guid id, Info info) : base(registry, id) + { + InfoID = info.id; + } + + public readonly record struct Info(int id); + public int InfoID { get; } + } + + class ManualGlobalAsset : Asset + { + public ManualGlobalAsset(IAssetRegistry registry, Guid id, Info info) : base(registry, id) + { + this.info = info; + info.Asset = this; + } + + public class Info(TaskContinuationOptions tcsOptions, int id, bool waitForSecondary = true) : IEquatable + { + public readonly int ID = id; + public readonly bool WaitForSecondary = waitForSecondary; + public readonly TaskCompletionSource> Completion = new(); + public readonly TaskCompletionSource WasStarted = new(); + public readonly TaskCompletionSource WasUnloaded = new(); + public ManualGlobalAsset? Asset { get; set; } + + public void Complete(params AssetHandle[] secondary) => Completion.SetResult(secondary); + public void Fail() => Completion.SetException(new IOException("Oh no, something failed")); + + bool IEquatable.Equals(Info? other) => ID == other?.ID; + } + + public int InfoID => info.ID; + protected override bool NeedsSecondaryAssets => info.WaitForSecondary; + private readonly Info info; + + protected override IEnumerable Load() + { + info.WasStarted.SetResult(); // throws if we enter twice. Good. + if (info.Completion.Task.IsCompletedSuccessfully) + return info.Completion.Task.Result; + else + return LoadAsynchronously; + } + + protected override Task> LoadAsync() => info.Completion.Task; + + protected override void Unload() + { + info.WasUnloaded.SetResult(); + } + } + + static TestAssetRegistry() + { + AssetInfoRegistry.Register(AssetLocality.Global); + AssetInfoRegistry.Register(AssetLocality.Context); + AssetInfoRegistry.Register(AssetLocality.SingleUsage); + + AssetInfoRegistry.Register(AssetLocality.Global); + } + + private TagContainer diContainer; + private AssetRegistry globalRegistry; + private AssetLocalRegistry localRegistry; + private Stopwatch stopwatch = new(); + private readonly TaskContinuationOptions tcsOptions; + + public TestAssetRegistry(TaskContinuationOptions tcsOptions) => this.tcsOptions = tcsOptions; + + [SetUp] + public void Setup() + { + diContainer = new(); + diContainer.AddTag(Serilog.Core.Logger.None); + diContainer.AddTag(globalRegistry = new AssetRegistry("Global", diContainer)); + localRegistry = new AssetLocalRegistry("Local", diContainer); + TestContext.CurrentContext.CancellationToken.Register(() => Cleanup("Cancellation")); + stopwatch.Start(); + } + + [TearDown] + public void TearDown() => Cleanup("TearDown"); + + private void Cleanup(string reason) + { + if (!stopwatch.IsRunning) + return; + try + { + var beforeDispose = stopwatch.Elapsed.TotalSeconds; + localRegistry.Dispose(); + diContainer.Dispose(); + stopwatch.Stop(); + var afterDispose = stopwatch.Elapsed.TotalSeconds; + if (reason == "Cancellation") + Assert.Fail($"Test was cancelled during {reason} in {TestContext.CurrentContext.Test.Name}, most likely due to a timeout ({beforeDispose:F3}, {afterDispose:F3})."); + } + catch(Exception e) + { + Assert.Fail($"Exception during {reason} in {TestContext.CurrentContext.Test.Name}: {e}"); + } + } + + [Test] + public void EmptyConstruction() {} + + [Test] + public void LoadSyncGlobalAsset_GlobalRegistry() + { + var assetHandle = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, null); + Assert.That(assetHandle.IsLoaded); + Assert.That(assetHandle.Registry, Is.SameAs(globalRegistry)); + Assert.That(assetHandle.AssetID, Is.Not.Default); + + var asset = assetHandle.Get(); + Assert.That(asset.ID, Is.EqualTo(assetHandle.AssetID)); + Assert.That(asset.Registry, Is.SameAs(globalRegistry)); + Assert.That(asset.State, Is.EqualTo(AssetState.Loaded)); + Assert.That(asset.InfoID, Is.EqualTo(42)); + + assetHandle.Dispose(); // Because this is a synchronous asset, after this method + globalRegistry.ApplyAssets(); // the asset should be queued up for deletion already + + Assert.That(asset.State, Is.EqualTo(AssetState.Disposed)); + } + + // TODO: Reconsider AssetHandle.Registry from user-facing perspective + + [Test] + public void LoadSyncGlobalAsset_LocalRegistry() + { + // just check that it works at all + using var assetHandle = localRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, null); + Assert.That(assetHandle.IsLoaded); + Assert.That(assetHandle.Registry, Is.SameAs(globalRegistry)); + Assert.That(assetHandle.Get().InfoID, Is.EqualTo(42)); + } + + [Test] + public void LoadSyncContextAsset_GlobalRegistry() + { + Assert.That(() => + { + globalRegistry.Load(new SynchronousContextAsset.Info(42), AssetLoadPriority.Synchronous, null); + }, Throws.InvalidOperationException); + } + + [Test] + public void LoadSyncContextAsset_LocalRegistry() + { + using var assetHandle = localRegistry.Load(new SynchronousContextAsset.Info(42), AssetLoadPriority.Synchronous, null); + Assert.That(assetHandle.IsLoaded); + // Assert.That(assetHandle.Registry, Is.SameAs(localRegistry)); // TODO: Fix this before merge + Assert.That(assetHandle.Get().InfoID, Is.EqualTo(42)); + } + + [Test] + public void LoadSyncSingleUsageAsset_GlobalRegistry() + { + Assert.That(() => + { + globalRegistry.Load(new SynchronousSingleUsageAsset.Info(42), AssetLoadPriority.Synchronous, null); + }, Throws.InvalidOperationException); + } + + [Test] + public void LoadSyncSingleUsageAsset_LocalRegistry() + { + using var assetHandle = localRegistry.Load(new SynchronousSingleUsageAsset.Info(42), AssetLoadPriority.Synchronous, null); + Assert.That(assetHandle.IsLoaded); + // Assert.That(assetHandle.Registry, Is.SameAs(localRegistry)); // TODO: Fix this before merge + Assert.That(assetHandle.Get().InfoID, Is.EqualTo(42)); + } + + [Test] + public void LoadSyncGlobalAsset_MultipleLoads() + { + using var assetHandleA1 = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, null); + using var assetHandleA2 = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, null); + using var assetHandleB1 = globalRegistry.Load(new SynchronousGlobalAsset.Info(1337), AssetLoadPriority.Synchronous, null); + using var assetHandleA3 = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, null); + + Assert.That(assetHandleA1, Is.EqualTo(assetHandleA2)); + Assert.That(assetHandleA2, Is.EqualTo(assetHandleA3)); + Assert.That(assetHandleA3, Is.Not.EqualTo(assetHandleB1)); + + Assert.That(assetHandleA1.AssetID, Is.EqualTo(assetHandleA2.AssetID)); + Assert.That(assetHandleA1.AssetID, Is.Not.EqualTo(assetHandleB1.AssetID)); + + var assetA1 = assetHandleA1.Get(); + var assetA2 = assetHandleA2.Get(); + var assetB1 = assetHandleB1.Get(); + Assert.That(assetA1, Is.SameAs(assetA2)); + Assert.That(assetA1, Is.Not.SameAs(assetB1)); + } + + [Test] + public void LoadSyncContextAsset_MultipleLoads() + { + using var assetHandleA1 = localRegistry.Load(new SynchronousContextAsset.Info(42), AssetLoadPriority.Synchronous, null); + using var assetHandleA2 = localRegistry.Load(new SynchronousContextAsset.Info(42), AssetLoadPriority.Synchronous, null); + using var assetHandleB1 = localRegistry.Load(new SynchronousContextAsset.Info(1337), AssetLoadPriority.Synchronous, null); + using var assetHandleA3 = localRegistry.Load(new SynchronousContextAsset.Info(42), AssetLoadPriority.Synchronous, null); + + Assert.That(assetHandleA1, Is.EqualTo(assetHandleA2)); + Assert.That(assetHandleA2, Is.EqualTo(assetHandleA3)); + Assert.That(assetHandleA3, Is.Not.EqualTo(assetHandleB1)); + + Assert.That(assetHandleA1.AssetID, Is.EqualTo(assetHandleA2.AssetID)); + Assert.That(assetHandleA1.AssetID, Is.Not.EqualTo(assetHandleB1.AssetID)); + + var assetA1 = assetHandleA1.Get(); + var assetA2 = assetHandleA2.Get(); + var assetB1 = assetHandleB1.Get(); + Assert.That(assetA1, Is.SameAs(assetA2)); + Assert.That(assetA1, Is.Not.SameAs(assetB1)); + } + + [Test] + public void LoadSyncContextAsset_MultipleLocalRegistries() + { + using var localRegistry2 = new AssetLocalRegistry("OtherLocal", diContainer); + using var assetHandle1 = localRegistry.Load(new SynchronousContextAsset.Info(42), AssetLoadPriority.Synchronous, null); + using var assetHandle2 = localRegistry2.Load(new SynchronousContextAsset.Info(42), AssetLoadPriority.Synchronous, null); + + Assert.That(assetHandle1.AssetID, Is.EqualTo(assetHandle2.AssetID)); + Assert.That(assetHandle1, Is.Not.EqualTo(assetHandle2)); + + var asset1 = assetHandle1.Get(); + var asset2 = assetHandle2.Get(); + Assert.That(asset1, Is.Not.SameAs(asset2)); + } + + [Test] + public void LoadSyncSingleUsageAsset_MultipleLoads() + { + using var assetHandle1 = localRegistry.Load(new SynchronousSingleUsageAsset.Info(42), AssetLoadPriority.Synchronous, null); + using var assetHandle2 = localRegistry.Load(new SynchronousSingleUsageAsset.Info(42), AssetLoadPriority.Synchronous, null); + + Assert.That(assetHandle1, Is.Not.EqualTo(assetHandle2)); + + var asset1 = assetHandle1.Get(); + var asset2 = assetHandle2.Get(); + Assert.That(asset1, Is.Not.SameAs(asset2)); + + assetHandle1.Dispose(); + Assert.That(assetHandle2.IsLoaded); + Assert.That(asset2.State, Is.EqualTo(AssetState.Loaded)); + Assert.That(asset1.State, Is.EqualTo(AssetState.Disposed)); + } + + [Test] + public void ApplySyncAsset_Action() + { + int callCount = 0; + using var assetHandle = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, assetHandle => + { + callCount++; + Assert.That(assetHandle.IsLoaded); + Assert.That(assetHandle.Get().InfoID, Is.EqualTo(42)); + }); + + Assert.That(callCount, Is.EqualTo(1)); + } + + private static void IncrementIntegerSync(AssetHandle assetHandle, in StrongBox callCount) + { + Assert.That(assetHandle.IsLoaded); + Assert.That(assetHandle.Get().InfoID, Is.EqualTo(42)); + callCount.Value++; + } + + [Test] + public unsafe void ApplySyncAsset_FnPtr() + { + StrongBox callCount = new(0); + using var assetHandle = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, + &IncrementIntegerSync, callCount); + Assert.That(callCount.Value, Is.EqualTo(1)); + } + + [Test] + public unsafe void ApplySyncAsset_ApplyAfterLoad() + { + StrongBox callCountFnPtr = new(0); + using var assetHandle1 = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, + &IncrementIntegerSync, callCountFnPtr); + Assert.That(callCountFnPtr.Value, Is.EqualTo(1)); + + int callCountAction = 0; + using var assetHandle2 = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, + _ => callCountAction++); + Assert.That(callCountAction, Is.EqualTo(1)); + + Assert.That(callCountFnPtr.Value, Is.EqualTo(1)); // checks we have not called the previous apply action twice + } + + [Test] + public unsafe void ApplySyncAsset_OverHandle() + { + using var assetHandle = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, null); + + StrongBox callCountFnPtr = new(0); + assetHandle.Apply(&IncrementIntegerSync, callCountFnPtr); + Assert.That(callCountFnPtr.Value, Is.EqualTo(1)); + + int callCountAction = 0; + assetHandle.Apply(_ => callCountAction++); + Assert.That(callCountAction, Is.EqualTo(1)); + + Assert.That(callCountFnPtr.Value, Is.EqualTo(1)); + } + + [Test] + public async Task ApplySyncAsset_AsyncApplyByLoad() + { + using var assetHandle = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, null); + + int callCountAction = 0; + var mainThreadId = Environment.CurrentManagedThreadId; + await Task.Factory.StartNew(async () => + { + Assert.That(Environment.CurrentManagedThreadId, Is.Not.EqualTo(mainThreadId)); + using var secondHandle = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.High, _ => callCountAction++); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(secondHandle); + }, TaskCreationOptions.LongRunning); // this option should ensure a new thread + + Assert.That(callCountAction, Is.EqualTo(0)); // otherwise the apply action would have been called on a non-main thread + globalRegistry.ApplyAssets(); + Assert.That(callCountAction, Is.EqualTo(1)); + } + + [Test] + public async Task ApplySyncAsset_AsyncApplyByHandle() + { + using var assetHandle = globalRegistry.Load(new SynchronousGlobalAsset.Info(42), AssetLoadPriority.Synchronous, null); + + int callCountAction = 0; + var mainThreadId = Environment.CurrentManagedThreadId; + await Task.Factory.StartNew(async () => + { + Assert.That(Environment.CurrentManagedThreadId, Is.Not.EqualTo(mainThreadId)); + assetHandle.Apply(_ => callCountAction++); + }, TaskCreationOptions.LongRunning); // this option should ensure a new thread + + Assert.That(callCountAction, Is.EqualTo(0)); // otherwise the apply action would have been called on a non-main thread + globalRegistry.ApplyAssets(); + Assert.That(callCountAction, Is.EqualTo(1)); + } + + [Test] + public void LoadAsyncAsset_HighSync() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.High, null); + Assert.That(assetHandle.IsLoaded, Is.False); + // we cannot reason about WasStarted, as it is called asynchronously + Assert.That(assetInfo.WasUnloaded.Task.IsCompleted, Is.False); + + assetInfo.Complete(); + var asset = assetHandle.Get(); + + Assert.That(assetHandle.IsLoaded, Is.True); + Assert.That(assetInfo.WasStarted.Task.IsCompletedSuccessfully, Is.True); + Assert.That(assetInfo.WasUnloaded.Task.IsCompleted, Is.False); + } + + [Test] + public void LoadAsyncAsset_LowSync() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, null); + Assert.That(assetHandle.IsLoaded, Is.False); + Assert.That(assetInfo.WasStarted.Task.IsCompleted, Is.False); + Assert.That(assetInfo.WasUnloaded.Task.IsCompleted, Is.False); + + globalRegistry.ApplyAssets(); + assetInfo.Complete(); + var asset = assetHandle.Get(); + + Assert.That(assetHandle.IsLoaded, Is.True); + Assert.That(assetInfo.WasStarted.Task.IsCompletedSuccessfully, Is.True); + Assert.That(assetInfo.WasUnloaded.Task.IsCompleted, Is.False); + } + + [Test] + public async Task LoadAsyncAsset_HighAsync() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.High, null); + Assert.That(assetHandle.IsLoaded, Is.False); + // we cannot reason about WasStarted, as it is called asynchronously + Assert.That(assetInfo.WasUnloaded.Task.IsCompleted, Is.False); + + assetInfo.Complete(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle); + + Assert.That(assetHandle.IsLoaded, Is.True); + Assert.That(assetInfo.WasStarted.Task.IsCompletedSuccessfully, Is.True); + Assert.That(assetInfo.WasUnloaded.Task.IsCompleted, Is.False); + } + + [Test] + public async Task LoadAsyncAsset_LowAsync() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, null); + Assert.That(assetHandle.IsLoaded, Is.False); + Assert.That(assetInfo.WasStarted.Task.IsCompleted, Is.False); + Assert.That(assetInfo.WasUnloaded.Task.IsCompleted, Is.False); + + globalRegistry.ApplyAssets(); + assetInfo.Complete(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle); + + Assert.That(assetHandle.IsLoaded, Is.True); + Assert.That(assetInfo.WasStarted.Task.IsCompletedSuccessfully, Is.True); + Assert.That(assetInfo.WasUnloaded.Task.IsCompleted, Is.False); + } + + [Test] + public async Task LoadAsyncAsset_LowThenHigh() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + assetInfo.Complete(); + + using var assetHandleLow = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, null); + using var assetHandleHigh = globalRegistry.Load(assetInfo, AssetLoadPriority.High, null); + + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandleHigh); + Assert.That(assetHandleHigh.IsLoaded, Is.True); // without ever ApplyAssets to start loading as low + + globalRegistry.ApplyAssets(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandleLow); + + Assert.That(assetHandleLow.IsLoaded, Is.True); // mainly checking that no exception came during the second load + } + + [Test] + public async Task LoadAsyncAsset_LowThenSynchronous() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + assetInfo.Complete(); + + using var assetHandleLow = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, null); + using var assetHandleSync = globalRegistry.Load(assetInfo, AssetLoadPriority.Synchronous, null); + + Assert.That(assetHandleLow.IsLoaded, Is.True); + + globalRegistry.ApplyAssets(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandleSync); + + Assert.That(assetHandleSync.IsLoaded, Is.True); + } + + [Test] + public async Task LoadAsyncAsset_RegistryDisposal() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.High, null); + await assetInfo.WasStarted.Task; + + Task.Run(async () => { await Task.Delay(200); assetInfo.Complete(); }); + globalRegistry.Dispose(); + } + + [Test] + public async Task UnloadAsyncAsset_HighNormal() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.High, null); + assetInfo.Complete(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle); + Assert.That(assetHandle.IsLoaded, Is.True); + + assetHandle.Dispose(); + globalRegistry.ApplyAssets(); + + Assert.That(assetHandle.IsLoaded, Is.False); + Assert.That(assetInfo.WasUnloaded.Task.IsCompletedSuccessfully, Is.True); + } + + [Test] + public async Task UnloadAsyncAsset_LowNormal() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, null); + assetInfo.Complete(); + globalRegistry.ApplyAssets(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle); + Assert.That(assetHandle.IsLoaded, Is.True); + + assetHandle.Dispose(); + globalRegistry.ApplyAssets(); + + Assert.That(assetHandle.IsLoaded, Is.False); + Assert.That(assetInfo.WasUnloaded.Task.IsCompletedSuccessfully, Is.True); + } + + [Test] + public async Task UnloadAsyncAsset_HighDuringLoad() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.High, null); + await assetInfo.WasStarted.Task; + + assetHandle.Dispose(); + globalRegistry.ApplyAssets(); + assetInfo.Complete(); + + Assert.That(assetHandle.IsLoaded, Is.False); + Assert.That(assetInfo.WasUnloaded.Task.IsCompletedSuccessfully, Is.True); + } + + [Test] + public async Task UnloadAsyncAsset_LowDuringLoad() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, null); + globalRegistry.ApplyAssets(); + await assetInfo.WasStarted.Task; + + assetHandle.Dispose(); + globalRegistry.ApplyAssets(); + assetInfo.Complete(); + + Assert.That(assetHandle.IsLoaded, Is.False); + Assert.That(assetInfo.WasUnloaded.Task.IsCompletedSuccessfully, Is.True); + } + + [Test] + public async Task UnloadAsyncAsset_HighBeforeLoad() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.High, null); + assetHandle.Dispose(); + if (assetInfo.WasStarted.Task.IsCompletedSuccessfully) + // yes, this is not fully correct but also not terrible if *this* test not always succeeds + Assert.Inconclusive("Asset was already started, unsynchronizable test will not be conclusive"); + + assetInfo.Complete(); + await Task.Yield(); + + Assert.That(assetHandle.IsLoaded, Is.False); + Assert.That(assetInfo.WasUnloaded.Task.IsCompletedSuccessfully, Is.True); + } + + [Test] + public async Task UnloadAsyncAsset_LowBeforeLoad() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, null); + assetHandle.Dispose(); + globalRegistry.ApplyAssets(); + + assetInfo.Complete(); + await Task.Yield(); + + Assert.That(assetHandle.IsLoaded, Is.False); + Assert.That(assetInfo.WasUnloaded.Task.IsCompletedSuccessfully, Is.True); + } + + [Test] + public Task UnloadAsyncAsset_High_AccessDisposedHandle() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.High, null); + if (assetInfo.WasStarted.Task.IsCompletedSuccessfully) + Assert.Inconclusive("Asset was already started, unsynchronizable test will not be conclusive"); + assetHandle.Dispose(); + globalRegistry.ApplyAssets(); + + assetInfo.Complete(); + return Assert.ThatAsync( + () => (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle), + Throws.InstanceOf()); + } + + [Test] + public Task UnloadAsyncAsset_Low_AccessDisposedHandle() + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, null); + assetHandle.Dispose(); + globalRegistry.ApplyAssets(); + + assetInfo.Complete(); + return Assert.ThatAsync( + () => (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle), + Throws.InstanceOf()); + } + + private static void IncrementIntegerAsync(AssetHandle assetHandle, in StrongBox callCount) + { + Assert.That(assetHandle.IsLoaded); + Assert.That(assetHandle.Get().InfoID, Is.EqualTo(42)); + callCount.Value++; + } + + private unsafe void ApplyIncrementInteger(AssetHandle assetHandle, StrongBox callCount) + { + assetHandle.Apply(&IncrementIntegerAsync, callCount); + } + + [Test] + public async Task ApplyAsyncAsset_BeforeLoadingStarted() + { + int callCountAction = 0; + StrongBox callCountFnPtr = new(0); + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, null); + + assetHandle.Apply(_ => callCountAction++); + ApplyIncrementInteger(assetHandle, callCountFnPtr); // we cannot use unsafe but can call unsafe functions... + + globalRegistry.ApplyAssets(); + assetInfo.Complete(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle); + globalRegistry.ApplyAssets(); // preapply actions are run on main thread, so another call is necessary for pre-load apply actions + + Assert.That(assetHandle.IsLoaded); + Assert.That(callCountAction, Is.EqualTo(1)); + Assert.That(callCountFnPtr.Value, Is.EqualTo(1)); + } + + [Test] + public async Task ApplyAsyncAsset_DuringLoad() + { + int callCountAction = 0; + StrongBox callCountFnPtr = new(0); + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.High, null); + + await assetInfo.WasStarted.Task; + + assetHandle.Apply(_ => callCountAction++); + ApplyIncrementInteger(assetHandle, callCountFnPtr); // we cannot use unsafe but can call unsafe functions... + + assetInfo.Complete(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle); + globalRegistry.ApplyAssets(); + + Assert.That(assetHandle.IsLoaded); + Assert.That(callCountAction, Is.EqualTo(1)); + Assert.That(callCountFnPtr.Value, Is.EqualTo(1)); + } + + [Test] + public async Task ApplyAsyncAsset_AfterLoad() + { + int callCountAction = 0; + StrongBox callCountFnPtr = new(0); + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.High, null); + + assetInfo.Complete(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle); + Assert.That(assetHandle.IsLoaded); + + assetHandle.Apply(_ => callCountAction++); + Assert.That(callCountAction, Is.EqualTo(1)); // if the asset was loaded, apply actions are to be run synchronously + + ApplyIncrementInteger(assetHandle, callCountFnPtr); + Assert.That(callCountFnPtr.Value, Is.EqualTo(1)); + } + + [Test] + public async Task ApplyAsyncAsset_StoredLow() + { + const int Low = 1, Sync = 2; + var actions = new List(2); + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 42); + assetInfo.Complete(); + + using var assetHandleLow = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, _ => actions.Add(Low)); + using var assetHandleSync = globalRegistry.Load(assetInfo, AssetLoadPriority.Synchronous, _ => actions.Add(Sync)); + + Assert.That(actions, Is.EquivalentTo(new int[] { Low, Sync })); + + globalRegistry.ApplyAssets(); // provoke the registry to do something stupid + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandleLow); + + Assert.That(actions, Is.EquivalentTo(new int[] { Low, Sync })); + } + + [Test] + public async Task SecondaryAssets_SingleSync() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 42); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 1337); + assetInfoSecondary.Complete(); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + + await assetInfoPrimary.WasStarted.Task; + var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.Synchronous, null); + assetInfoPrimary.Complete(assetHandleSecondary); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary); + + Assert.That(assetHandlePrimary.IsLoaded); + Assert.That(assetHandleSecondary.IsLoaded); + } + + [Test] + public async Task SecondaryAssets_SingleHigh() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 42); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 1337); + assetInfoSecondary.Complete(); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + + await assetInfoPrimary.WasStarted.Task; + var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.High, null); + assetInfoPrimary.Complete(assetHandleSecondary); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary); + + Assert.That(assetHandlePrimary.IsLoaded); + Assert.That(assetHandleSecondary.IsLoaded); + } + + [Test] + public async Task SecondaryAssets_SingleLow() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 42); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 1337); + assetInfoSecondary.Complete(); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + + await assetInfoPrimary.WasStarted.Task; + var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.Low, null); + assetInfoPrimary.Complete(assetHandleSecondary); + globalRegistry.ApplyAssets(); // necessary to start secondary loading + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary); + + Assert.That(assetHandlePrimary.IsLoaded); + Assert.That(assetHandleSecondary.IsLoaded); + } + + [Test] + public async Task SecondaryAssets_MultipleSync() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 42); + var assetInfoSecondary1 = new ManualGlobalAsset.Info(tcsOptions, 1337); + var assetInfoSecondary2 = new ManualGlobalAsset.Info(tcsOptions, 1338); + assetInfoSecondary1.Complete(); + assetInfoSecondary2.Complete(); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + + await assetInfoPrimary.WasStarted.Task; + var assetHandleSecondary1 = globalRegistry.Load(assetInfoSecondary1, AssetLoadPriority.Synchronous, null); + var assetHandleSecondary2 = globalRegistry.Load(assetInfoSecondary2, AssetLoadPriority.Synchronous, null); + assetInfoPrimary.Complete([assetHandleSecondary1, assetHandleSecondary2]); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary); + + Assert.That(assetHandlePrimary.IsLoaded); + Assert.That(assetHandleSecondary1.IsLoaded); + Assert.That(assetHandleSecondary2.IsLoaded); + } + + [Test] + public async Task SecondaryAssets_PartiallyAsync() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 42); + var assetInfoSecondary1 = new ManualGlobalAsset.Info(tcsOptions, 1337); + var assetInfoSecondary2 = new ManualGlobalAsset.Info(tcsOptions, 1338); + assetInfoSecondary1.Complete(); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + + await assetInfoPrimary.WasStarted.Task; + var assetHandleSecondary1 = globalRegistry.Load(assetInfoSecondary1, AssetLoadPriority.Synchronous, null); + var assetHandleSecondary2 = globalRegistry.Load(assetInfoSecondary2, AssetLoadPriority.High, null); + assetInfoPrimary.Complete([assetHandleSecondary1, assetHandleSecondary2]); + assetInfoSecondary2.Complete(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary); + + Assert.That(assetHandlePrimary.IsLoaded); + Assert.That(assetHandleSecondary1.IsLoaded); + Assert.That(assetHandleSecondary2.IsLoaded); + } + + [Test] + public async Task SecondaryAssets_FullyAsync() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 42); + var assetInfoSecondary1 = new ManualGlobalAsset.Info(tcsOptions, 1337); + var assetInfoSecondary2 = new ManualGlobalAsset.Info(tcsOptions, 1338); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + + await assetInfoPrimary.WasStarted.Task; + var assetHandleSecondary1 = globalRegistry.Load(assetInfoSecondary1, AssetLoadPriority.High, null); + var assetHandleSecondary2 = globalRegistry.Load(assetInfoSecondary2, AssetLoadPriority.High, null); + assetInfoPrimary.Complete([assetHandleSecondary1, assetHandleSecondary2]); + assetInfoSecondary2.Complete(); + assetInfoSecondary1.Complete(); // reverse completion order, why not. + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary); + + Assert.That(assetHandlePrimary.IsLoaded); + Assert.That(assetHandleSecondary1.IsLoaded); + Assert.That(assetHandleSecondary2.IsLoaded); + } + + [Test] + public async Task SecondaryAssets_HighNoWait() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 42, waitForSecondary: false); + var assetInfoSecondary1 = new ManualGlobalAsset.Info(tcsOptions, 1337); + var assetInfoSecondary2 = new ManualGlobalAsset.Info(tcsOptions, 1338); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + + await assetInfoPrimary.WasStarted.Task; + var assetHandleSecondary1 = globalRegistry.Load(assetInfoSecondary1, AssetLoadPriority.High, null); + var assetHandleSecondary2 = globalRegistry.Load(assetInfoSecondary2, AssetLoadPriority.High, null); + assetInfoPrimary.Complete([assetHandleSecondary1, assetHandleSecondary2]); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary); + + Assert.That(assetHandlePrimary.IsLoaded); + Assert.That(assetHandleSecondary1.IsLoaded, Is.False); + Assert.That(assetHandleSecondary2.IsLoaded, Is.False); + + assetInfoSecondary1.Complete(); // I don't want to task cancellation behavior in *this* test + assetInfoSecondary2.Complete(); + } + + [Test] + public async Task SecondaryAssets_LowNoWait() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 42, waitForSecondary: false); + var assetInfoSecondary1 = new ManualGlobalAsset.Info(tcsOptions, 1337); + var assetInfoSecondary2 = new ManualGlobalAsset.Info(tcsOptions, 1338); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + + await assetInfoPrimary.WasStarted.Task; + var assetHandleSecondary1 = globalRegistry.Load(assetInfoSecondary1, AssetLoadPriority.Low, null); + var assetHandleSecondary2 = globalRegistry.Load(assetInfoSecondary2, AssetLoadPriority.Low, null); + assetInfoPrimary.Complete([assetHandleSecondary1, assetHandleSecondary2]); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary); + + Assert.That(assetHandlePrimary.IsLoaded); + Assert.That(assetHandleSecondary1.IsLoaded, Is.False); + Assert.That(assetHandleSecondary2.IsLoaded, Is.False); + } + + [Test] + public async Task SecondaryAssets_TransitiveWait() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + var assetInfoTertiary = new ManualGlobalAsset.Info(tcsOptions, 3); + var assetInfoQuaternary = new ManualGlobalAsset.Info(tcsOptions, 4); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.High, null); + using var assetHandleTertiary = globalRegistry.Load(assetInfoTertiary, AssetLoadPriority.High, null); + using var assetHandleQuaternary = globalRegistry.Load(assetInfoQuaternary, AssetLoadPriority.High, null); + + assetInfoPrimary.Complete([assetHandleSecondary]); + assetInfoSecondary.Complete([assetHandleTertiary]); + assetInfoTertiary.Complete([assetHandleQuaternary]); + assetInfoQuaternary.Complete(); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary); + + Assert.That(assetHandlePrimary.IsLoaded); + Assert.That(assetHandleSecondary.IsLoaded); + Assert.That(assetHandleTertiary.IsLoaded); + Assert.That(assetHandleQuaternary.IsLoaded); + } + + [Test, Ignore("Recursive waits are broken and NUnit does not report this well at the moment")] + public async Task SecondaryAssets_RecursiveWait() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + var assetInfoTertiary = new ManualGlobalAsset.Info(tcsOptions, 3); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.High, null); + using var assetHandleTertiary = globalRegistry.Load(assetInfoTertiary, AssetLoadPriority.High, null); + + assetInfoPrimary.Complete([assetHandleSecondary]); + assetInfoSecondary.Complete([assetHandleTertiary]); + assetInfoTertiary.Complete([assetHandlePrimary]); + + await Assert.ThatAsync( + async () => await (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary), + Throws.InvalidOperationException); + + Assert.That(assetHandlePrimary.IsLoaded, Is.False); + Assert.That(assetHandleSecondary.IsLoaded, Is.False); + Assert.That(assetHandleTertiary.IsLoaded, Is.False); + } + + [Test] + public async Task SecondaryAssets_RegistryDisposal() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.High, null); + + assetInfoPrimary.Complete([assetHandleSecondary]); + await assetInfoSecondary.WasStarted.Task; + + Task.Run(async () => { await Task.Delay(200); assetInfoSecondary.Complete(); }); + globalRegistry.Dispose(); + } + + [Test] + public void Error_PrimarySync() + { + var applyActionCount = 0; + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 1); + assetInfo.Fail(); + + Assert.That(() => + { + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.Synchronous, _ => applyActionCount++); + }, Throws.InstanceOf()); + + Assert.That(applyActionCount, Is.Zero); + Assert.That(assetInfo.WasUnloaded.Task.IsCompletedSuccessfully); + } + + [Test] + public async Task Error_PrimaryHigh() + { + var applyActionCount = 0; + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 1); + + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.High, _ => applyActionCount++); + assetInfo.Fail(); + await Assert.ThatAsync( + () => (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle), + Throws.InstanceOf().Or.InstanceOf()); + + Assert.That(assetHandle.IsLoaded, Is.False); + Assert.That(applyActionCount, Is.Zero); + Assert.That(assetInfo.WasUnloaded.Task.IsCompletedSuccessfully); + } + + [Test] + public async Task Error_PrimaryLow() + { + var applyActionCount = 0; + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 1); + + using var assetHandle = globalRegistry.Load(assetInfo, AssetLoadPriority.Low, _ => applyActionCount++); + globalRegistry.ApplyAssets(); + assetInfo.Fail(); + await Assert.ThatAsync( + () => (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandle), + Throws.InstanceOf().Or.InstanceOf()); + + Assert.That(assetHandle.IsLoaded, Is.False); + Assert.That(applyActionCount, Is.Zero); + Assert.That(assetInfo.WasUnloaded.Task.IsCompletedSuccessfully); + } + + // testing SyncSecondarySync does not make much sense. As the secondary load would have to be before + // loading primary the test case just degrades to Error_PrimarySync. + // We test transitive errors in any other of these Error_*Secondary* tests + + [Test] + public void Error_SyncSecondaryHigh() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + assetInfoSecondary.Fail(); + + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.High, null); + assetInfoPrimary.Complete([assetHandleSecondary]); + Assert.That(() => + { + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.Synchronous, null); + }, Throws.InstanceOf().Or.InstanceOf()); + + Assert.That(assetHandleSecondary.IsLoaded, Is.False); + Assert.That(assetInfoPrimary.WasUnloaded.Task.IsCompletedSuccessfully); + Assert.That(assetInfoSecondary.WasUnloaded.Task.IsCompletedSuccessfully); + } + + [Test] + public void Error_SyncSecondaryLow() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + assetInfoSecondary.Fail(); + + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.Low, null); + assetInfoPrimary.Complete([assetHandleSecondary]); + globalRegistry.ApplyAssets(); + Assert.That(() => + { + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.Synchronous, null); + }, Throws.InstanceOf().Or.InstanceOf()); + + Assert.That(assetHandleSecondary.IsLoaded, Is.False); + Assert.That(assetInfoPrimary.WasUnloaded.Task.IsCompletedSuccessfully); + Assert.That(assetInfoSecondary.WasUnloaded.Task.IsCompletedSuccessfully); + } + + [Test] + public async Task Error_HighSecondarySync() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + assetInfoSecondary.Fail(); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + await assetInfoPrimary.WasStarted.Task; + try + { + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.Synchronous, null); + Assert.Fail("Expected the secondary asset load to throw an exception"); + } + catch(Exception ex) // we do not have a handle to the secondary, but the exception would be thrown in the Load method of the primary + { + assetInfoPrimary.Completion.SetException(ex); + } + + await Assert.ThatAsync( + () => (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary), + Throws.InstanceOf().Or.InstanceOf()); + + Assert.That(assetHandlePrimary.IsLoaded, Is.False); + Assert.That(assetInfoPrimary.WasUnloaded.Task.IsCompletedSuccessfully); + Assert.That(assetInfoSecondary.WasUnloaded.Task.IsCompletedSuccessfully); + } + + [Test] + public async Task Error_HighSecondaryHigh() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + await assetInfoPrimary.WasStarted.Task; + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.High, null); + assetInfoPrimary.Complete([assetHandleSecondary]); + await assetInfoSecondary.WasStarted.Task; + assetInfoSecondary.Fail(); + + await Assert.ThatAsync( + () => (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary), + Throws.InstanceOf().Or.InstanceOf()); + + Assert.That(assetHandlePrimary.IsLoaded, Is.False); + Assert.That(assetHandleSecondary.IsLoaded, Is.False); + Assert.That(assetInfoPrimary.WasUnloaded.Task.IsCompletedSuccessfully); + Assert.That(assetInfoSecondary.WasUnloaded.Task.IsCompletedSuccessfully); + } + + [Test] + public async Task Error_HighSecondaryLow() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.High, null); + await assetInfoPrimary.WasStarted.Task; + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.Low, null); + assetInfoPrimary.Complete([assetHandleSecondary]); + globalRegistry.ApplyAssets(); + await assetInfoSecondary.WasStarted.Task; + assetInfoSecondary.Fail(); + + await Assert.ThatAsync( + () => (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary), + Throws.InstanceOf().Or.InstanceOf()); + + Assert.That(assetHandlePrimary.IsLoaded, Is.False); + Assert.That(assetHandleSecondary.IsLoaded, Is.False); + Assert.That(assetInfoPrimary.WasUnloaded.Task.IsCompletedSuccessfully); + Assert.That(assetInfoSecondary.WasUnloaded.Task.IsCompletedSuccessfully); + } + + [Test] + public async Task Error_LowSecondarySync() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + assetInfoSecondary.Fail(); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.Low, null); + globalRegistry.ApplyAssets(); + await assetInfoPrimary.WasStarted.Task; + try + { + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.Synchronous, null); + Assert.Fail("Expected the secondary asset load to throw an exception"); + } + catch(Exception ex) // we do not have a handle to the secondary, but the exception would be thrown in the Load method of the primary + { + assetInfoPrimary.Completion.SetException(ex); + } + + await Assert.ThatAsync( + () => (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary), + Throws.InstanceOf().Or.InstanceOf()); + + Assert.That(assetHandlePrimary.IsLoaded, Is.False); + Assert.That(assetInfoPrimary.WasUnloaded.Task.IsCompletedSuccessfully); + Assert.That(assetInfoSecondary.WasUnloaded.Task.IsCompletedSuccessfully); + } + + [Test] + public async Task Error_LowSecondaryHigh() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.Low, null); + globalRegistry.ApplyAssets(); + await assetInfoPrimary.WasStarted.Task; + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.High, null); + assetInfoPrimary.Complete([assetHandleSecondary]); + await assetInfoSecondary.WasStarted.Task; + assetInfoSecondary.Fail(); + + await Assert.ThatAsync( + () => (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary), + Throws.InstanceOf()); + + Assert.That(assetHandlePrimary.IsLoaded, Is.False); + Assert.That(assetHandleSecondary.IsLoaded, Is.False); + Assert.That(assetInfoPrimary.WasUnloaded.Task.IsCompletedSuccessfully); + Assert.That(assetInfoSecondary.WasUnloaded.Task.IsCompletedSuccessfully); + } + + [Test] + public async Task Error_LowSecondaryLow() + { + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary = new ManualGlobalAsset.Info(tcsOptions, 2); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.Low, null); + globalRegistry.ApplyAssets(); + await assetInfoPrimary.WasStarted.Task; + using var assetHandleSecondary = globalRegistry.Load(assetInfoSecondary, AssetLoadPriority.Low, null); + assetInfoPrimary.Complete([assetHandleSecondary]); + globalRegistry.ApplyAssets(); + await assetInfoSecondary.WasStarted.Task; + assetInfoSecondary.Fail(); + + await Assert.ThatAsync( + () => (globalRegistry as IAssetRegistry).WaitAsyncAll(assetHandlePrimary), + Throws.InstanceOf()); + + Assert.That(assetHandlePrimary.IsLoaded, Is.False); + Assert.That(assetHandleSecondary.IsLoaded, Is.False); + Assert.That(assetInfoPrimary.WasUnloaded.Task.IsCompletedSuccessfully); + Assert.That(assetInfoSecondary.WasUnloaded.Task.IsCompletedSuccessfully); + } + + // TODO: Why has this no Test attribute? + public async Task Error_DeadlockAssetStateAndSecondaryException() + { + // motivation is that we want to create a scenario where: + // - primary waits on one thread (mistakenly holding the asset state lock) + // - secondary on another thread failed and sets its completion exception + // - the completion continues synchronously + // - primary wants to sets its error and tries to lock the asset state. + // -> deadlock + + var assetInfoPrimary = new ManualGlobalAsset.Info(tcsOptions, 1); + var assetInfoSecondary1 = new ManualGlobalAsset.Info(tcsOptions, 2); + var assetInfoSecondary2 = new ManualGlobalAsset.Info(tcsOptions, 3); + assetInfoSecondary1.Fail(); + assetInfoSecondary2.Fail(); + + using var assetHandlePrimary = globalRegistry.Load(assetInfoPrimary, AssetLoadPriority.Low, null); + assetInfoPrimary.WasStarted.Task.ContinueWith(_ => + { + var assetHandleSecondary1 = globalRegistry.Load(assetInfoSecondary1, AssetLoadPriority.High, null); + var assetHandleSecondary2 = globalRegistry.Load(assetInfoSecondary2, AssetLoadPriority.High, null); + assetInfoPrimary.Complete([assetHandleSecondary1, assetHandleSecondary2]); + }, + TestContext.CurrentContext.CancellationToken, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Current); + Assert.That(() => + { + assetHandlePrimary.Get(); + }, Throws.InstanceOf()); + + Assert.That(assetInfoPrimary.WasUnloaded.Task.IsCompletedSuccessfully); + Assert.That(assetInfoSecondary1.WasUnloaded.Task.IsCompletedSuccessfully); + Assert.That(assetInfoSecondary2.WasUnloaded.Task.IsCompletedSuccessfully); + } + + private async Task LoadAndWait(AssetLoadPriority priority, bool shouldFail, bool waitForCompletion) + { + var assetInfo = new ManualGlobalAsset.Info(tcsOptions, 1); + if (shouldFail) + assetInfo.Fail(); + else + assetInfo.Complete(); + if (shouldFail && waitForCompletion) + { + await Assert.ThatAsync(async () => + { + using var handle = globalRegistry.Load(assetInfo, priority, null); + await (globalRegistry as IAssetRegistry).WaitAsyncAll(handle); + }, Throws.InstanceOf().Or.InstanceOf()); + } + else if (shouldFail && priority is AssetLoadPriority.Synchronous) + { + // for asynchronous loads the fail is not triggered by the load, only by wait + Assert.That(async () => + { + using var handle = globalRegistry.Load(assetInfo, priority, null); + }, Throws.InstanceOf().Or.InstanceOf()); + } + else + { + using var handle = globalRegistry.Load(assetInfo, priority, null); + if (waitForCompletion) + await (globalRegistry as IAssetRegistry).WaitAsyncAll(handle); + } + } + + [Test] + public async Task ReloadAsset_Disposal([Values] AssetLoadPriority priority, [Values] bool waitForCompletion) + { + await LoadAndWait(priority, shouldFail: false, waitForCompletion); + await LoadAndWait(priority, shouldFail: false, waitForCompletion); + await LoadAndWait(priority, shouldFail: false, waitForCompletion); + } + + [Test] + public async Task ReloadAsset_Error([Values] AssetLoadPriority priority, [Values] bool waitForCompletion) + { + await LoadAndWait(priority, shouldFail: true, waitForCompletion); + await LoadAndWait(priority, shouldFail: true, waitForCompletion); + await LoadAndWait(priority, shouldFail: true, waitForCompletion); + } + + [Test] + public async Task ReloadAsset_MixedErrorAndDisposal([Values] AssetLoadPriority priority, [Values] bool waitForCompletion) + { + await LoadAndWait(priority, shouldFail: true, waitForCompletion); + await LoadAndWait(priority, shouldFail: false, waitForCompletion); + await LoadAndWait(priority, shouldFail: true, waitForCompletion); + await LoadAndWait(priority, shouldFail: false, waitForCompletion); + } +} diff --git a/zzre.core.tests/zzre.core.tests.csproj b/zzre.core.tests/zzre.core.tests.csproj index 4eab2a12..f098c8c9 100644 --- a/zzre.core.tests/zzre.core.tests.csproj +++ b/zzre.core.tests/zzre.core.tests.csproj @@ -6,6 +6,7 @@ enable NU1605;nullable false + true diff --git a/zzre.core/OnceAction.cs b/zzre.core/OnceAction.cs index 523f00df..692b7364 100644 --- a/zzre.core/OnceAction.cs +++ b/zzre.core/OnceAction.cs @@ -12,6 +12,12 @@ public void Invoke() Next = null; next?.Invoke(); } + public Action? Reset() + { + var result = Next; + Next = null; + return result; + } } public class OnceAction @@ -24,6 +30,12 @@ public void Invoke(T1 a) Next = null; next?.Invoke(a); } + public Action? Reset() + { + var result = Next; + Next = null; + return result; + } } public class OnceAction @@ -36,6 +48,12 @@ public void Invoke(T1 a, T2 b) Next = null; next?.Invoke(a, b); } + public Action? Reset() + { + var result = Next; + Next = null; + return result; + } } public class OnceAction @@ -48,4 +66,10 @@ public void Invoke(T1 a, T2 b, T3 c) Next = null; next?.Invoke(a, b, c); } + public Action? Reset() + { + var result = Next; + Next = null; + return result; + } } diff --git a/zzre.core/TaskExtensions.cs b/zzre.core/TaskExtensions.cs index 7f687224..cc39a385 100644 --- a/zzre.core/TaskExtensions.cs +++ b/zzre.core/TaskExtensions.cs @@ -17,4 +17,18 @@ public static void WaitAndRethrow(this Task task) ExceptionDispatchInfo.Capture(ex.InnerException!).Throw(); } } + + // from https://github.com/dotnet/runtime/issues/47605 + public static async Task WithAggregateException(this Task source) + { + try + { + await source.ConfigureAwait(false); + } + catch(Exception e) + { + if (source.Exception == null) throw new AggregateException([e]); // however that happens... + ExceptionDispatchInfo.Throw(source.Exception); + } + } } diff --git a/zzre.core/assetregistry/Asset.cs b/zzre.core/assetregistry/Asset.cs index a42053f1..b411cede 100644 --- a/zzre.core/assetregistry/Asset.cs +++ b/zzre.core/assetregistry/Asset.cs @@ -1,7 +1,9 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; @@ -27,7 +29,7 @@ public enum AssetState } /// The internal interface into an asset -internal interface IAsset : IDisposable +internal interface IAsset { Guid ID { get; } AssetState State { get; } @@ -35,70 +37,82 @@ internal interface IAsset : IDisposable Task LoadTask { get; } /// The priority that the asset was *effectively* loaded with AssetLoadPriority Priority { get; set; } - /// The current reference count to be atomically modified to keep assets alive or disposing them + /// The current reference count to be modified to keep assets alive or disposing them /// Modifying has to be done using the and methods int RefCount { get; } /// The *stored* apply actions to be taken after loading /// This will not include immediate apply actions if the asset is loaded synchronously or was already loaded OnceAction ApplyAction { get; } + SemaphoreSlim StateLock { get; } + /// Starts the loading of the asset on the thread pool /// This call is ignored if the is not void StartLoading(); - /// Synchronously completes loading of the asset - /// This call will also rethrow loading exceptions - void Complete(); - /// Atomically increases the reference count + /// Increases the reference count void AddRef(); - /// Atomically decreases the reference count and disposes the asset if necessary + /// Decreases the reference count and disposes the asset if necessary /// It will also signal a disposal to the registry void DelRef(); /// Rethrows a loading exception that already occured /// Assumes that the load task has already completed with an error void ThrowIfError(); + void Dispose(); } /// The base class for asset types -public abstract class Asset : IAsset +/// Only call the constructor with data given by a registry +/// The apparent registry of this asset to report and load secondary assets from +/// The ID chosen by the registry for this asset +public abstract class Asset(IAssetRegistry registry, Guid id) : IAsset { - protected static ValueTask> NoSecondaryAssets => - ValueTask.FromResult(Enumerable.Empty()); + private sealed class LoadAsynchronousSentinel : IEnumerable + { + public IEnumerator GetEnumerator() => + throw new InvalidOperationException("This is a sentinel value, you cannot use this for anything other than ReferenceEquals"); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + protected static readonly IEnumerable NoSecondaryAssets = []; + protected static readonly IEnumerable LoadAsynchronously = new LoadAsynchronousSentinel(); /// The of the apparent registry to be used during loading - protected readonly ITagContainer diContainer; - private readonly TaskCompletionSource completionSource = new(); + protected readonly ITagContainer diContainer = registry.DIContainer; + private readonly TaskCompletionSource completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); private string? description; private AssetHandle[] secondaryAssets = []; private int refCount; + private AssetState state; - private IAssetRegistryInternal InternalRegistry { get; } - public IAssetRegistry Registry { get; } + private IAssetRegistryInternal InternalRegistry { get; } = registry.InternalRegistry; + public IAssetRegistry Registry { get; } = registry; /// An unique identifier given by the registry related to the Info value in order to efficiently address an asset instance /// The ID is chosen randomly and will change at least per process per asset - public Guid ID { get; } + public Guid ID { get; } = id; /// The current loading state of the asset - public AssetState State { get; private set; } + public AssetState State + { + get => state; + private set + { + if (state is AssetState.Disposed or AssetState.Error && + value is not (AssetState.Disposed or AssetState.Error)) + throw new ArgumentException("Invalid asset state transition"); + state = value; + } + } Task IAsset.LoadTask => completionSource.Task; int IAsset.RefCount => refCount; AssetLoadPriority IAsset.Priority { get; set; } OnceAction IAsset.ApplyAction { get; } = new(); + SemaphoreSlim IAsset.StateLock { get; } = new(1, 1); - /// Constructs the base information for an asset - /// Only call this constructor with data given by a registry - /// The apparent registry of this asset to report and load secondary assets from - /// The ID chosen by the registry for this asset - public Asset(IAssetRegistry registry, Guid id) - { - Registry = registry; - InternalRegistry = registry.InternalRegistry; - diContainer = registry.DIContainer; - ID = id; - } - - void IDisposable.Dispose() + void IAsset.Dispose() { - if (State != AssetState.Error) - State = AssetState.Disposed; + Debug.Assert((this as IAsset).StateLock.CurrentCount == 0); + if (State is AssetState.Disposed or AssetState.Error) + return; + State = AssetState.Disposed; Unload(); @@ -109,135 +123,135 @@ void IDisposable.Dispose() void IAsset.StartLoading() { - lock (this) - { - if (State != AssetState.Queued) - return; - State = AssetState.Loading; - Task.Run(PrivateLoad, InternalRegistry.Cancellation); - } + Debug.Assert((this as IAsset).StateLock.CurrentCount == 0 && State == AssetState.Queued); + State = AssetState.Loading; + Task.Run(PrivateLoad); // we do not want a cancelalation in order to change the state from Loading at some point } - void IAsset.Complete() + void IAsset.AddRef() { - lock (this) - { - switch (State) - { - case AssetState.Loaded: return; - - case AssetState.Loading: - case AssetState.LoadingSecondary: - completionSource.Task.WaitAndRethrow(); - return; - - case AssetState.Queued: - State = AssetState.Loading; - PrivateLoad().WaitAndRethrow(); - return; - - case AssetState.Disposed: - throw new ObjectDisposedException(ToString()); - case AssetState.Error: - (this as IAsset).ThrowIfError(); - return; - - default: - throw new NotImplementedException($"Unimplemented asset state {State}"); - } - } + Debug.Assert((this as IAsset).StateLock.CurrentCount == 0); + refCount++; } - void IAsset.AddRef() => Interlocked.Increment(ref refCount); void IAsset.DelRef() { - int oldRefCount; - while (true) - { - oldRefCount = refCount; - if (oldRefCount <= 0) - return; - if (Interlocked.CompareExchange(ref refCount, oldRefCount - 1, oldRefCount) == oldRefCount) - break; - } - if (oldRefCount == 1) // we just hit zero - { - lock (this) - { - (this as IAsset).Dispose(); - InternalRegistry.QueueRemoveAsset(this).AsTask().WaitAndRethrow(); - } - } + Debug.Assert((this as IAsset).StateLock.CurrentCount == 0); + if (--refCount != 0) + return; + + (this as IAsset).Dispose(); + InternalRegistry.QueueRemoveAsset(this); } private async Task PrivateLoad() { - if (State != AssetState.Loading) + // TODO: This will currently not work when disposed while loading + + if (State != AssetState.Loading) // TODO: Check throwing exception here, is it uncaught if it ever happens? throw new InvalidOperationException("Asset.PrivateLoad was called during an unexpected state"); var ct = InternalRegistry.Cancellation; try { - var secondaryAssetSet = await Load(); - secondaryAssets = secondaryAssetSet.ToArray(); - EnsureLocality(secondaryAssets); - ct.ThrowIfCancellationRequested(); - - if (secondaryAssets.Length > 0 && NeedsSecondaryAssets) + var secondaryAssetSet = Load(); + if (ReferenceEquals(secondaryAssetSet, LoadAsynchronously)) + secondaryAssetSet = await LoadAsync(); + if (ReferenceEquals(secondaryAssetSet, LoadAsynchronously)) + throw new InvalidOperationException("LoadAsync is not allowed to return LoadAsynchronously"); + if (!ReferenceEquals(secondaryAssetSet, NoSecondaryAssets)) { - lock (this) + PrepareSecondaryAssets(secondaryAssetSet); + ct.ThrowIfCancellationRequested(); + + if (secondaryAssets.Length > 0 && NeedsSecondaryAssets) { - State = AssetState.LoadingSecondary; + (this as IAsset).StateLock.Wait(Registry.InternalRegistry.Cancellation); + try + { + Debug.Assert(State == AssetState.Loading); + State = AssetState.LoadingSecondary; + } + finally + { + (this as IAsset).StateLock.Release(); + } + await InternalRegistry.WaitAsyncAll(secondaryAssets); } - await InternalRegistry.WaitAsyncAll(secondaryAssets); } ct.ThrowIfCancellationRequested(); State = AssetState.Loaded; + InternalRegistry.QueueApplyAsset(this); completionSource.SetResult(); - await InternalRegistry.QueueApplyAsset(this); } catch (Exception ex) { - lock(this) + try + { + (this as IAsset).StateLock.Wait(Registry.InternalRegistry.Cancellation); + try + { + (this as IAsset).Dispose(); + } + finally + { + (this as IAsset).StateLock.Release(); + } + } + finally { State = AssetState.Error; - completionSource.SetException(ex); - (this as IDisposable).Dispose(); + completionSource.TrySetException(ex); } } } void IAsset.ThrowIfError() { - if (State == AssetState.Error) +#pragma warning disable CA1513 // Use ObjectDisposedException throw helper + if (State is AssetState.Error) { - completionSource.Task.WaitAndRethrow(); - throw new InvalidOperationException("Asset was marked erroneous but does not contain exception"); + var exception = completionSource.Task.Exception; + if (exception is null) + throw new InvalidOperationException("Asset was marked erroneous but does not contain exception"); + else + ExceptionDispatchInfo.Throw(exception.InnerException ?? exception); } + else if (State is AssetState.Disposed) + throw new ObjectDisposedException(ToString()); +#pragma warning restore CA1513 // Use ObjectDisposedException throw helper } - [Conditional("DEBUG")] - private void EnsureLocality(AssetHandle[] secondaryAssets) + private void PrepareSecondaryAssets(IEnumerable secondaryAssetSet) { - foreach (var secondary in secondaryAssets) + secondaryAssets = secondaryAssetSet.ToArray(); + foreach (ref var secondary in secondaryAssets.AsSpan()) + { if (!InternalRegistry.IsLocalRegistry && secondary.registryInternal.IsLocalRegistry) throw new InvalidOperationException("Global assets cannot load local assets as secondary ones"); + secondary = new(secondary); // increments the reference count + } } /// Whether marking this asset as loaded should be deferred until all secondary asset are loaded as well protected virtual bool NeedsSecondaryAssets { get; } = true; - /// Override this method to actually load the asset contents + /// Override this method to load the asset contents synchronously. /// This method can be called asynchronously + /// The set of secondary assets to be loaded from the same registry interface as this asset or + protected virtual IEnumerable Load() => LoadAsynchronously; + /// Override this method to load the asset contents asynchronously. + /// This method is only used if returns /// The set of secondary assets to be loaded from the same registry interface as this asset - protected abstract ValueTask> Load(); + protected virtual Task> LoadAsync() => + throw new NotImplementedException("Asset.Load returned LoadAsynchronously but LoadAsync was not implemented"); /// Unloads any resources the asset might hold /// It is not necessary to manually dispose secondary asset handles protected abstract void Unload(); /// Produces a description of the asset to be shown in debug logs and tools /// A description of the asset instance for debugging - public override sealed string ToString() => description ??= ToStringInner(); + public sealed override string ToString() => description ??= ToStringInner(); /// Produces a description of the asset to be shown in debug logs and tools /// Used to cache description strings diff --git a/zzre.core/assetregistry/AssetHandle.cs b/zzre.core/assetregistry/AssetHandle.cs index 09c6712e..5df7648d 100644 --- a/zzre.core/assetregistry/AssetHandle.cs +++ b/zzre.core/assetregistry/AssetHandle.cs @@ -24,7 +24,7 @@ public readonly bool IsLoaded { get { - CheckDisposed(); + CheckDefault(); return registryInternal.IsLoaded(AssetID); } } @@ -50,6 +50,15 @@ internal AssetHandle(AssetRegistry registry, Guid assetId) AssetID = assetId; } + internal AssetHandle(AssetHandle original) + { + original.CheckDisposed(); + registryInternal = original.registryInternal; + handleScope = original.handleScope; + AssetID = original.AssetID; + registryInternal.AddRefOf(original.AssetID); + } + /// Disposes the stake on the asset this handle is tied to /// *May* trigger disposal of the asset and related secondary assets public void Dispose() @@ -64,9 +73,13 @@ public void Dispose() } [Conditional("DEBUG")] - private readonly void CheckDisposed() => + internal readonly void CheckDisposed() => ObjectDisposedException.ThrowIf(wasDisposed || AssetID == Guid.Empty, this); + [Conditional("DEBUG")] + private readonly void CheckDefault() => + ObjectDisposedException.ThrowIf(AssetID == Guid.Empty, this); + /// Returns a loaded asset instance /// The asset has to be marked as , otherwise it will try to synchronously wait for loading completion /// The actual type of the asset instance @@ -92,7 +105,7 @@ public readonly AssetHandle As() where TValue : Asset /// The type of the apply context given to the apply action /// The function pointer to call as apply action /// The apply context given to the apply action - public unsafe readonly void Apply( + public readonly unsafe void Apply( delegate* managed applyFnptr, in TApplyContext applyContext) { @@ -100,19 +113,6 @@ public unsafe readonly void Apply( registryInternal.AddApplyAction(this, applyFnptr, in applyContext); } - /// Adds an apply action to the asset - /// Depending on whether the asset is already loaded the action will be called immediately or only stored for later execution - /// The type of the apply context given to the apply action - /// The delegate to call as apply action - /// The apply context given to the apply action - public readonly void Apply( - IAssetRegistry.ApplyWithContextAction applyAction, - in TApplyContext applyContext) - { - CheckDisposed(); - registryInternal.AddApplyAction(this, applyAction, in applyContext); - } - /// Adds an apply action to the asset /// Depending on whether the asset is already loaded the action will be called immediately or only stored for later execution /// The delegate to call as apply action @@ -122,11 +122,11 @@ public readonly void Apply(Action applyAction) registryInternal.AddApplyAction(this, applyAction); } - public readonly override string ToString() => $"AssetHandle {AssetID}"; + public override readonly string ToString() => $"AssetHandle {AssetID}"; public override readonly bool Equals(object? obj) => obj is AssetHandle handle && Equals(handle); - public readonly bool Equals(AssetHandle other) => AssetID.Equals(other.AssetID); - public override readonly int GetHashCode() => HashCode.Combine(AssetID); + public readonly bool Equals(AssetHandle other) => AssetID.Equals(other.AssetID) && ReferenceEquals(registryInternal, other.registryInternal); + public override readonly int GetHashCode() => HashCode.Combine(AssetID, registryInternal); public static bool operator ==(AssetHandle left, AssetHandle right) => left.Equals(right); public static bool operator !=(AssetHandle left, AssetHandle right) => !(left == right); } @@ -134,7 +134,7 @@ public readonly void Apply(Action applyAction) /// A typed asset handle for convenience /// The actual type is only checked upon retrieval of the instance /// The type of the asset instance -public struct AssetHandle : IDisposable, IEquatable>, IEquatable +public readonly struct AssetHandle : IDisposable, IEquatable>, IEquatable where TValue : Asset { /// @@ -151,7 +151,7 @@ public struct AssetHandle : IDisposable, IEquatable> /// public readonly TValue Get() => Inner.Get(); - public readonly override string ToString() => $"AssetHandle<{typeof(TValue).Name}> {Inner.AssetID}"; + public override readonly string ToString() => $"AssetHandle<{typeof(TValue).Name}> {Inner.AssetID}"; public static bool operator ==(AssetHandle left, AssetHandle right) => left.Equals(right); public static bool operator !=(AssetHandle left, AssetHandle right) => !(left == right); diff --git a/zzre.core/assetregistry/AssetInfoRegistry.cs b/zzre.core/assetregistry/AssetInfoRegistry.cs index 37a846b4..6734f704 100644 --- a/zzre.core/assetregistry/AssetInfoRegistry.cs +++ b/zzre.core/assetregistry/AssetInfoRegistry.cs @@ -98,11 +98,10 @@ internal static Guid ToGuid(in TInfo info) internal static TInfo ToInfo(Guid assetId) { EnsureRegistered(); - lock(@lock) + lock (@lock) { - if (guidToInfo.TryGetValue(assetId, out var info)) - return info; - throw new KeyNotFoundException($"Could not find registered info for {assetId}"); + return guidToInfo.TryGetValue(assetId, out var info) ? info + : throw new KeyNotFoundException($"Could not find registered info for {assetId}"); } } } diff --git a/zzre.core/assetregistry/AssetRegistry,Debug.cs b/zzre.core/assetregistry/AssetRegistry.Debug.cs similarity index 91% rename from zzre.core/assetregistry/AssetRegistry,Debug.cs rename to zzre.core/assetregistry/AssetRegistry.Debug.cs index ff57d12e..dcc9d943 100644 --- a/zzre.core/assetregistry/AssetRegistry,Debug.cs +++ b/zzre.core/assetregistry/AssetRegistry.Debug.cs @@ -23,12 +23,12 @@ public readonly record struct AssetInfo( /// Whether this registry was already disposed bool WasDisposed { get; } - /// Creats a snapshot of the state of all, currently registered assets + /// Creates a snapshot of the state of all, currently registered assets /// The asset states will be copied into this list void CopyDebugInfo(List assetInfos); } -partial class AssetRegistry : IAssetRegistryDebug +public partial class AssetRegistry : IAssetRegistryDebug { void IAssetRegistryDebug.CopyDebugInfo(List assetInfos) { diff --git a/zzre.core/assetregistry/AssetRegistry.ECS.cs b/zzre.core/assetregistry/AssetRegistry.ECS.cs index 85d45de3..6237bbfb 100644 --- a/zzre.core/assetregistry/AssetRegistry.ECS.cs +++ b/zzre.core/assetregistry/AssetRegistry.ECS.cs @@ -1,6 +1,6 @@ namespace zzre; -partial class AssetRegistry +public partial class AssetRegistry { /// /// Adds watchers to automatically dispose components when they are removed from an entity or the world is disposed diff --git a/zzre.core/assetregistry/AssetRegistry.Internal.cs b/zzre.core/assetregistry/AssetRegistry.Internal.cs index 77312442..36d5294b 100644 --- a/zzre.core/assetregistry/AssetRegistry.Internal.cs +++ b/zzre.core/assetregistry/AssetRegistry.Internal.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; -using System.Linq; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; namespace zzre; -partial class AssetRegistry +public partial class AssetRegistry { CancellationToken IAssetRegistryInternal.Cancellation => cancellationSource.Token; bool IAssetRegistryInternal.IsLocalRegistry => apparentRegistry != this; @@ -15,26 +15,53 @@ void IAssetRegistryInternal.DisposeHandle(AssetHandle handle) { if (handle.Registry != this) throw new ArgumentException("Tried to unload asset at wrong registry"); + if (WasDisposed) + return; // not particularly nice, but all assets should be disposed anyway so ignoring handle disposal should be fine + IAsset? asset = null; lock (assets) + asset = assets.GetValueOrDefault(handle.AssetID); + if (asset is null) + throw new InvalidOperationException("Asset was not found or already deleted, this should not happen if you used a valid handle"); + asset.StateLock.Wait(Cancellation); + try + { + asset.DelRef(); + } + finally { - if (assets.TryGetValue(handle.AssetID, out var asset)) - asset.DelRef(); + asset.StateLock.Release(); } } + [Flags] + private enum AddApplyActions + { + Execute = 1 << 0, + Add = 1 << 1, + Queue = 1 << 2 + } + private IAsset? TryGetForApplying(AssetHandle handle) { lock (assets) + return assets.GetValueOrDefault(handle.AssetID); + } + + private AddApplyActions DecideForApplying(IAsset? asset) + { + if (asset is null || asset.State is AssetState.Error or AssetState.Disposed) + return default; + + AddApplyActions actions = default; + if (asset.State is AssetState.Loaded && IsMainThread) + actions = AddApplyActions.Execute; + else { - var asset = assets.GetValueOrDefault(handle.AssetID); - if (asset is not null) - { - asset.ThrowIfError(); - if (asset.State == AssetState.Disposed) - asset = null; - } - return asset; + actions |= AddApplyActions.Add; + if (asset.State is AssetState.Loaded) + actions |= AddApplyActions.Queue; } + return actions; } unsafe void IAssetRegistryInternal.AddApplyAction(AssetHandle handle, @@ -44,37 +71,29 @@ unsafe void IAssetRegistryInternal.AddApplyAction(AssetHandle han var asset = TryGetForApplying(handle); if (asset is null) return; - lock (asset) + + AddApplyActions actions; + Action? previousApplyActions = null; + asset.StateLock.Wait(Cancellation); + try { - if (asset.State == AssetState.Loaded && IsMainThread) - applyFnptr(handle, in applyContext); - else - { + actions = DecideForApplying(asset); + if (actions.HasFlag(AddApplyActions.Execute)) + previousApplyActions = asset.ApplyAction.Reset(); + if (actions.HasFlag(AddApplyActions.Add)) asset.ApplyAction.Next += ConvertFnptr(applyFnptr, in applyContext); - if (asset.State == AssetState.Loaded) - assetsToApply.Writer.WriteAsync(asset, Cancellation).AsTask().Wait(); - } } - } + finally + { + asset.StateLock.Release(); + } - void IAssetRegistryInternal.AddApplyAction(AssetHandle handle, - IAssetRegistry.ApplyWithContextAction applyAction, - in TApplyContext applyContext) - { - var asset = TryGetForApplying(handle); - if (asset is null) - return; - lock (asset) + if (actions.HasFlag(AddApplyActions.Queue)) + assetsToApply.Writer.TryWrite(asset); + if (actions.HasFlag(AddApplyActions.Execute)) { - if (asset.State == AssetState.Loaded && IsMainThread) - applyAction(handle, in applyContext); - else - { - var applyContextCopy = applyContext; - asset.ApplyAction.Next += handle => applyAction(handle, in applyContextCopy); - if (asset.State == AssetState.Loaded) - assetsToApply.Writer.WriteAsync(asset, Cancellation).AsTask().Wait(); - } + previousApplyActions?.Invoke(handle); + applyFnptr(handle, in applyContext); } } @@ -84,53 +103,85 @@ void IAssetRegistryInternal.AddApplyAction(AssetHandle handle, var asset = TryGetForApplying(handle); if (asset is null) return; - lock (asset) + + AddApplyActions actions; + Action? previousApplyActions = null; + asset.StateLock.Wait(Cancellation); + try { - if (asset.State == AssetState.Loaded && IsMainThread) - applyAction(handle); - else - { + actions = DecideForApplying(asset); + if (actions.HasFlag(AddApplyActions.Execute)) + previousApplyActions = asset.ApplyAction.Reset(); + if (actions.HasFlag(AddApplyActions.Add) && applyAction is not null) asset.ApplyAction.Next += applyAction; - if (asset.State == AssetState.Loaded) - assetsToApply.Writer.WriteAsync(asset, Cancellation).AsTask().Wait(); - } + } + finally + { + asset.StateLock.Release(); + } + + if (actions.HasFlag(AddApplyActions.Queue)) + assetsToApply.Writer.TryWrite(asset); + if (actions.HasFlag(AddApplyActions.Execute)) + { + previousApplyActions?.Invoke(handle); + applyAction?.Invoke(handle); } } - ValueTask IAssetRegistryInternal.QueueRemoveAsset(IAsset asset) + void IAssetRegistryInternal.QueueRemoveAsset(IAsset asset) { stats.OnAssetRemoved(); if (IsMainThread) - { RemoveAsset(asset); - return ValueTask.CompletedTask; - } else - return assetsToRemove.Writer.WriteAsync(asset, Cancellation); + assetsToRemove.Writer.TryWrite(asset); } - ValueTask IAssetRegistryInternal.QueueApplyAsset(IAsset asset) + void IAssetRegistryInternal.QueueApplyAsset(IAsset asset) { stats.OnAssetLoaded(); if (IsMainThread) - { ApplyAsset(asset); - return ValueTask.CompletedTask; - } else - return assetsToApply.Writer.WriteAsync(asset, Cancellation); + assetsToApply.Writer.TryWrite(asset); } Task IAssetRegistry.WaitAsyncAll(AssetHandle[] secondaryHandles) { + // Task.WhenAll(IEnumerable) would create a List anyway. So we can use it + // to prefilter, which we have to do anyways + var secondaryTasks = new List(secondaryHandles.Length); lock (assets) { foreach (var handle in secondaryHandles) { - if (assets.TryGetValue(handle.AssetID, out var asset)) - asset.StartLoading(); + handle.CheckDisposed(); + if (!assets.TryGetValue(handle.AssetID, out var asset)) + continue; + bool hasLock = false; + try + { + asset.StateLock.Wait(Cancellation); + hasLock = true; + asset.ThrowIfError(); + if (asset.State == AssetState.Loaded) + continue; + else if (asset.State == AssetState.Queued) + asset.StartLoading(); + secondaryTasks.Add(asset.LoadTask); + } + catch(Exception e) // if the asset was already erroneous + { + throw new AggregateException([e]); + } + finally + { + if (hasLock) + asset.StateLock.Release(); + } } - return Task.WhenAll(secondaryHandles.Select(h => assets[h.AssetID].LoadTask)); + return Task.WhenAll(secondaryTasks).WithAggregateException(); } } @@ -140,10 +191,9 @@ bool IAssetRegistryInternal.IsLoaded(Guid assetId) IAsset? asset; lock (assets) asset = assets.GetValueOrDefault(assetId); - if (asset == null) - return false; - lock (asset) - return asset.State == AssetState.Loaded; + return asset is IAsset { State: AssetState.Loaded }; + // no use locking the asset. If there is non-synchronized access it could just as well change before + // the caller decides on their action based on our return value. } TAsset IAssetRegistryInternal.GetLoadedAsset(Guid assetId) @@ -153,24 +203,76 @@ TAsset IAssetRegistryInternal.GetLoadedAsset(Guid assetId) asset = assets.GetValueOrDefault(assetId); if (asset == null) throw new InvalidOperationException("Asset is not present in registry"); - if (asset is not TAsset) + if (asset is not TAsset tasset) throw new InvalidOperationException($"Asset is not of type {typeof(TAsset).Name}"); - lock (asset) + + asset.StateLock.Wait(Cancellation); + try { - asset.ThrowIfError(); - switch (asset.State) - { - case AssetState.Disposed: - throw new ObjectDisposedException(asset.ToString()); - case AssetState.Loaded: - return (TAsset)asset; - default: - if (!IsMainThread) - throw new InvalidOperationException("Cannot synchronously wait for assets to load on secondary threads"); - asset.Complete(); - asset.ApplyAction.Invoke(new(this, assetId)); - return (TAsset)asset; - } + asset.ThrowIfError(); // so not disposed or error after this + if (asset.State is AssetState.Loaded) + return tasset; + } + finally + { + asset.StateLock.Release(); + } + + // We ended up having to (potentially start and) wait for loading. + // Let's use the normal synchronous load mechanism for that + Load(asset, AssetLoadPriority.Synchronous, applyAction: null, addRef: false); + Debug.Assert(asset.State == AssetState.Loaded); + return tasset; + } + + ValueTask IAssetRegistryInternal.GetLoadedAssetAsync(Guid assetId) + { + IAsset? asset; + lock (assets) + asset = assets.GetValueOrDefault(assetId); + if (asset == null) + throw new InvalidOperationException("Asset is not present in registry"); + if (asset is not TAsset tasset) + throw new InvalidOperationException($"Asset is not of type {typeof(TAsset).Name}"); + + asset.StateLock.Wait(Cancellation); + try + { + asset.ThrowIfError(); // so not disposed or error after this + if (asset.State is AssetState.Loaded) + return ValueTask.FromResult(tasset); + } + finally + { + asset.StateLock.Release(); + } + + // We still use the normal loading mechanism but asynchronously and wait ourselves + Load(asset, AssetLoadPriority.High, applyAction: null, addRef: false); + return new ValueTask(asset.LoadTask.ContinueWith(_ => + { + Debug.Assert(asset.State == AssetState.Loaded); // on exception we should have thrown already + return tasset; + })); + } + + void IAssetRegistryInternal.AddRefOf(Guid assetId) + { + IAsset? asset = null; + lock (assets) + asset = assets.GetValueOrDefault(assetId); + if (asset is null) + throw new InvalidOperationException("Asset was not found or already deleted, this should not happen if you used a valid handle"); + asset.StateLock.Wait(Cancellation); + try + { + if (asset.State is AssetState.Disposed) + throw new InvalidOperationException("Asset was already disposed, this should not happen if you used a valid handle"); + asset.AddRef(); + } + finally + { + asset.StateLock.Release(); } } } diff --git a/zzre.core/assetregistry/AssetRegistry.cs b/zzre.core/assetregistry/AssetRegistry.cs index 3748991b..55894cd7 100644 --- a/zzre.core/assetregistry/AssetRegistry.cs +++ b/zzre.core/assetregistry/AssetRegistry.cs @@ -10,6 +10,13 @@ namespace zzre; +/* + * As global note to the AssetRegistry, as far as I see using ChannelWriter.TryWrite without + * checking the result is correct, as we always use a SingleConsumerUnboundChannelWriter so + * the only way TryWrite returns false is if the writer was completed in which not writing + * the item is correct. + */ + /// A global asset registry to facilitate loading, retrieval and disposal of assets public sealed partial class AssetRegistry : zzio.BaseDisposable, IAssetRegistryInternal { @@ -48,43 +55,78 @@ internal AssetRegistry(string debugName, ITagContainer diContainer, IAssetRegist { DIContainer = diContainer; this.apparentRegistry = apparentRegistry ?? this; - if (string.IsNullOrEmpty(debugName)) - logger = diContainer.GetLoggerFor(); - else - logger = diContainer.GetTag().For($"{nameof(AssetRegistry)}-{debugName}"); + logger = string.IsNullOrEmpty(debugName) + ? diContainer.GetLoggerFor() + : diContainer.GetTag().For($"{nameof(AssetRegistry)}-{debugName}"); } protected override void DisposeManaged() { - EnsureMainThread(); + if (!IsMainThread) + logger.Warning("AssetRegistry.Dispose is called from a secondary thread."); cancellationSource.Cancel(); - Task.WhenAll(assets.Values - .Where(a => a.State is AssetState.Loading or AssetState.LoadingSecondary) - .Select(a => a.LoadTask)) - .Wait(10000); - foreach (var asset in assets.Values) - asset.Dispose(); - assets.Clear(); - assetsToRemove.Writer.Complete(); - assetsToApply.Writer.Complete(); - assetsToStart.Writer.Complete(); + try + { + if (!Task.WhenAll(assets.Values + .Where(a => a.State is AssetState.Loading or AssetState.LoadingSecondary) + .Select(a => a.LoadTask)) + .Wait(1000)) + throw new OperationCanceledException("Waiting for loading assets timed out."); + } + catch (Exception e) when (e is AggregateException or OperationCanceledException) + { + logger.Warning("Waiting for loading assets timed out. This is ignored and assets are disposed regardless."); + logger.Debug(e, "Waiting for loading assets timed out."); + } + if (!Monitor.TryEnter(assets, 1000)) + logger.Warning("Could not lock assets in AssetRegistry disposal. This is ignored and assets are disposed regardless."); + try + { + assetsToRemove.Writer.Complete(); + assetsToApply.Writer.Complete(); + assetsToStart.Writer.Complete(); + + bool failedToDisposeSomeAsset = false; + foreach (var asset in assets.Values) + { + if (asset.StateLock.Wait(500)) + { + asset.Dispose(); + asset.StateLock.Release(); + } + else if (!failedToDisposeSomeAsset) + { + logger.Warning("Failed to dispose all assets, someone holds an asset state lock for way to long"); + failedToDisposeSomeAsset = true; + } + } + assets.Clear(); + } + finally + { + if (Monitor.IsEntered(assets)) + Monitor.Exit(assets); + } cancellationSource.Dispose(); logger.Verbose("Finished disposing registry"); } private IAsset GetOrCreateAsset(in TInfo info) - where TInfo : IEquatable + where TInfo : IEquatable { Cancellation.ThrowIfCancellationRequested(); if (AssetInfoRegistry.Locality is not AssetLocality.Global && apparentRegistry == this) throw new InvalidOperationException("Cannot retrieve or create local assets in a global asset registry"); var guid = AssetInfoRegistry.ToGuid(info); - lock(assets) + lock (assets) { - if (!assets.TryGetValue(guid, out var asset) || asset.State is AssetState.Disposed) + if (!assets.TryGetValue(guid, out var asset) || asset.State is AssetState.Disposed or AssetState.Error) { - logger.Verbose("New {Type} asset {Info} ({ID})", AssetInfoRegistry.Name, info, guid); + if (asset is null) + logger.Verbose("New {Type} asset {Info} ({ID})", AssetInfoRegistry.Name, info, guid); + else + logger.Verbose("New {Type} asset {Info} ({ID}) (previous: {PreviousState})", AssetInfoRegistry.Name, info, guid, asset.State); stats.OnAssetCreated(); asset = AssetInfoRegistry.Construct(apparentRegistry, guid, in info); assets[guid] = asset; @@ -95,6 +137,43 @@ private IAsset GetOrCreateAsset(in TInfo info) } } + [Flags] + private enum LoadActions + { + Complete = 1 << 0, + Apply = 1 << 1, + QueueApply = 1 << 2 + } + + private LoadActions DecideLoadActions(IAsset asset, AssetLoadPriority priority) + { + Debug.Assert(asset.StateLock.CurrentCount == 0); + LoadActions actions = default; + + if (asset.State is AssetState.Queued) + { + if (priority is AssetLoadPriority.Low) + assetsToStart.Writer.TryWrite(asset); + else + asset.StartLoading(); + } + if (priority == AssetLoadPriority.Synchronous) + { + if (!IsMainThread) + throw new InvalidOperationException("Cannot load synchronous assets on secondary threads."); + actions |= LoadActions.Complete | LoadActions.Apply; + } + else + actions |= IsMainThread && asset.State is AssetState.Loaded + ? LoadActions.Apply + : LoadActions.QueueApply; + + // If we neither Apply nor QueueApply we lose apply actions + // If we both Apply and QueueApply we duplicate apply actions + Debug.Assert(actions.HasFlag(LoadActions.Apply) ^ actions.HasFlag(LoadActions.QueueApply)); + return actions; + } + /// public unsafe AssetHandle Load( in TInfo info, @@ -104,18 +183,34 @@ public unsafe AssetHandle Load( where TInfo : IEquatable { var asset = GetOrCreateAsset(in info); - lock (asset) + LoadActions loadActions; + Action? previousApplyActions = null; + + asset.StateLock.Wait(Cancellation); + try { - if (asset is { State: AssetState.Loaded } && IsMainThread) - { - // fast path: asset is already loaded and we only need to apply it - asset.AddRef(); - var handle = new AssetHandle(this, asset.ID); - applyFnptr(handle, in applyContext); - return handle; - } - return LoadInner(asset, priority, ConvertFnptr(applyFnptr, applyContext)); + asset.AddRef(); + loadActions = DecideLoadActions(asset, priority); + if (loadActions.HasFlag(LoadActions.Apply)) + previousApplyActions = asset.ApplyAction.Reset(); + if (loadActions.HasFlag(LoadActions.QueueApply)) + asset.ApplyAction.Next += ConvertFnptr(applyFnptr, in applyContext); } + finally + { + asset.StateLock.Release(); + } + + var handle = new AssetHandle(this, asset.ID); + if (loadActions.HasFlag(LoadActions.Complete)) + asset.LoadTask.WaitAndRethrow(); + asset.ThrowIfError(); + if (loadActions.HasFlag(LoadActions.Apply)) + { + previousApplyActions?.Invoke(handle); + applyFnptr(handle, in applyContext); + } + return handle; } private static unsafe Action ConvertFnptr( @@ -132,69 +227,43 @@ public AssetHandle Load( AssetLoadPriority priority, Action? applyAction) where TInfo : IEquatable + => Load(GetOrCreateAsset(in info), priority, applyAction, addRef: true); + + private AssetHandle Load( + IAsset asset, + AssetLoadPriority priority, + Action? applyAction, + bool addRef) { - var asset = GetOrCreateAsset(in info); - lock (asset) + LoadActions loadActions; + Action? previousApplyActions = null; + + asset.StateLock.Wait(Cancellation); + try { - if (asset is { State: AssetState.Loaded } && IsMainThread) - { + if (addRef) asset.AddRef(); - var handle = new AssetHandle(this, asset.ID); - applyAction?.Invoke(handle); - return handle; - } - return LoadInner(asset, priority, applyAction); + loadActions = DecideLoadActions(asset, priority); + if (loadActions.HasFlag(LoadActions.Apply)) + previousApplyActions = asset.ApplyAction.Reset(); + if (loadActions.HasFlag(LoadActions.QueueApply) && applyAction is not null) + asset.ApplyAction.Next += applyAction; } - } - - private AssetHandle LoadInner(IAsset asset, AssetLoadPriority priority, Action? applyAction) - { - // We assume that asset is locked for our thread during this method - asset.AddRef(); - var handle = new AssetHandle(this, asset.ID); - switch(asset.State) + finally { - case AssetState.Disposed or AssetState.Error: - throw new ArgumentException("LoadInner was called with asset in unexpected state"); - - case AssetState.Queued or AssetState.Loading or AssetState.LoadingSecondary: - if (applyAction is not null) - asset.ApplyAction.Next += applyAction; - if (asset.State == AssetState.Queued) - StartLoading(asset, priority); - return handle; - - case AssetState.Loaded: - if (IsMainThread) - applyAction?.Invoke(handle); - else if (applyAction is not null) - assetsToApply.Writer.WriteAsync(asset, Cancellation).AsTask().WaitAndRethrow(); - return handle; - - default: throw new NotImplementedException($"Unimplemented asset state {asset.State}"); + asset.StateLock.Release(); } - } - private void StartLoading(IAsset asset, AssetLoadPriority priority) - { - // We assume that asset is locked for our thread during this method - asset.Priority = priority; - switch (priority) + var handle = new AssetHandle(this, asset.ID); + if (loadActions.HasFlag(LoadActions.Complete)) + asset.LoadTask.WaitAndRethrow(); + asset.ThrowIfError(); + if (loadActions.HasFlag(LoadActions.Apply)) { - case AssetLoadPriority.Synchronous: - if (!asset.ApplyAction.IsEmpty && !IsMainThread) - throw new InvalidOperationException("Cannot load assets with Apply functions synchronously on secondary threads"); - asset.Complete(); - asset.ApplyAction.Invoke(new(this, asset.ID)); - break; - case AssetLoadPriority.High: - asset.StartLoading(); - break; - case AssetLoadPriority.Low: - assetsToStart.Writer.WriteAsync(asset, Cancellation).AsTask().WaitAndRethrow(); - break; - default: throw new NotImplementedException($"Unimplemented asset load priority {priority}"); + previousApplyActions?.Invoke(handle); + applyAction?.Invoke(handle); } + return handle; } private void RemoveAsset(IAsset asset) @@ -224,11 +293,16 @@ public void ApplyAssets() for (int i = 0; i < MaxLowPriorityAssetsPerFrame && assetsToStart.Reader.TryRead(out var asset); i++) { - lock (asset) + asset.StateLock.Wait(Cancellation); + try { if (asset.State == AssetState.Queued) asset.StartLoading(); } + finally + { + asset.StateLock.Release(); + } } } diff --git a/zzre.core/assetregistry/AssetRegistryStats.cs b/zzre.core/assetregistry/AssetRegistryStats.cs index 5cfdcf04..5782e17e 100644 --- a/zzre.core/assetregistry/AssetRegistryStats.cs +++ b/zzre.core/assetregistry/AssetRegistryStats.cs @@ -14,14 +14,14 @@ public struct AssetRegistryStats private int total; /// The number of assets created - public int Created => created; + public readonly int Created => created; /// The number of assets that finished loading - public int Loaded => loaded; + public readonly int Loaded => loaded; /// The number of assets removed from the registry - public int Removed => removed; + public readonly int Removed => removed; /// The number of currently registered assets /// This counter is not monotonous - public int Total => total; + public readonly int Total => total; internal void OnAssetCreated() { @@ -53,7 +53,7 @@ internal void OnAssetRemoved() total = rhs.total + lhs.total, }; - public override string ToString() + public override readonly string ToString() { var builder = new StringBuilder(256); builder.Append("Created: "); diff --git a/zzre.core/assetregistry/IAssetRegistry.cs b/zzre.core/assetregistry/IAssetRegistry.cs index 21bb0d4f..b94a60e2 100644 --- a/zzre.core/assetregistry/IAssetRegistry.cs +++ b/zzre.core/assetregistry/IAssetRegistry.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using System.Threading; + namespace zzre; /// Controls when an asset is actually loaded @@ -18,8 +19,6 @@ public enum AssetLoadPriority /// This interface may point to a local or a global registry public interface IAssetRegistry : IDisposable { - public delegate void ApplyWithContextAction(AssetHandle handle, in TApplyContext context); - internal IAssetRegistryInternal InternalRegistry { get; } /// The given to this registry at construction to be used for loading the asset contents ITagContainer DIContainer { get; } @@ -68,6 +67,12 @@ AssetHandle Load( /// Handles to the asset to wait for /// A task completing when all given assets finish loading Task WaitAsyncAll(AssetHandle[] assets) => InternalRegistry.WaitAsyncAll(assets); + + /// Asynchronously wait for one or more assets to finish loading + /// Use this method to wait for secondary assets + /// Handles to the asset to wait for + /// A task completing when all given assets finish loading + Task WaitAsyncAll(AssetHandle asset) => InternalRegistry.WaitAsyncAll([asset]); } internal interface IAssetRegistryInternal : IAssetRegistry @@ -81,16 +86,14 @@ unsafe void AddApplyAction(AssetHandle asset, delegate* managed applyFnptr, in TApplyContext applyContext); - void AddApplyAction(AssetHandle asset, - ApplyWithContextAction applyAction, - in TApplyContext applyContext); - void AddApplyAction(AssetHandle asset, Action applyAction); void DisposeHandle(AssetHandle handle); - ValueTask QueueRemoveAsset(IAsset asset); - ValueTask QueueApplyAsset(IAsset asset); + void QueueRemoveAsset(IAsset asset); + void QueueApplyAsset(IAsset asset); bool IsLoaded(Guid assetId); - TAsset GetLoadedAsset(Guid assetId); + void AddRefOf(Guid assetId); + TAsset GetLoadedAsset(Guid assetId) where TAsset : IAsset; + ValueTask GetLoadedAssetAsync(Guid assetId) where TAsset : IAsset; } diff --git a/zzre/assets/ActorAsset.cs b/zzre/assets/ActorAsset.cs index fc7cafb4..4d3ce549 100644 --- a/zzre/assets/ActorAsset.cs +++ b/zzre/assets/ActorAsset.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using zzio; using zzio.vfs; @@ -38,7 +37,7 @@ public ActorAsset(IAssetRegistry registry, Guid assetId, Info info) : base(regis this.info = info; } - protected override ValueTask> Load() + protected override IEnumerable Load() { var resourcePool = diContainer.GetTag(); using var stream = resourcePool.FindAndOpen(info.FullPath) ?? @@ -53,7 +52,7 @@ protected override ValueTask> Load() AddSecondaryHandles(secondaryHandles, ref outI, Body, bodyAnimations); if (description.HasWings) AddSecondaryHandles(secondaryHandles, ref outI, Wings, wingsAnimations); - return ValueTask.FromResult>(secondaryHandles); + return secondaryHandles; } private (AssetHandle, AssetHandle[]) LoadSecondaryPart(ActorPartDescription part) diff --git a/zzre/assets/AnimationAsset.cs b/zzre/assets/AnimationAsset.cs index 210f0136..d5f770ad 100644 --- a/zzre/assets/AnimationAsset.cs +++ b/zzre/assets/AnimationAsset.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using zzio; using zzio.vfs; @@ -30,7 +29,7 @@ public AnimationAsset(IAssetRegistry registry, Guid assetId, Info info) : base(r this.info = info; } - protected override ValueTask> Load() + protected override IEnumerable Load() { var resourcePool = diContainer.GetTag(); using var stream = resourcePool.FindAndOpen(info.FullPath) ?? diff --git a/zzre/assets/ClumpAsset.cs b/zzre/assets/ClumpAsset.cs index e358d68d..744c3452 100644 --- a/zzre/assets/ClumpAsset.cs +++ b/zzre/assets/ClumpAsset.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using zzio; using zzre.rendering; @@ -39,7 +38,7 @@ public ClumpAsset(IAssetRegistry registry, Guid assetId, Info info) : base(regis this.info = info; } - protected override ValueTask> Load() + protected override IEnumerable Load() { mesh = new ClumpMesh(diContainer, info.FullPath); return NoSecondaryAssets; diff --git a/zzre/assets/EffectCombinerAsset.cs b/zzre/assets/EffectCombinerAsset.cs index 225e03ac..8fe5ffeb 100644 --- a/zzre/assets/EffectCombinerAsset.cs +++ b/zzre/assets/EffectCombinerAsset.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using zzio; using zzio.effect; using zzio.vfs; @@ -28,7 +27,7 @@ public EffectCombinerAsset(IAssetRegistry registry, Guid assetId, Info info) : b this.info = info; } - protected override ValueTask> Load() + protected override IEnumerable Load() { var resourcePool = diContainer.GetTag(); using var stream = resourcePool.FindAndOpen(info.FullPath) ?? diff --git a/zzre/assets/EffectMaterialAsset.cs b/zzre/assets/EffectMaterialAsset.cs index 45c46647..eb522c05 100644 --- a/zzre/assets/EffectMaterialAsset.cs +++ b/zzre/assets/EffectMaterialAsset.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using Veldrid; using zzio; using zzio.effect; @@ -47,7 +46,7 @@ public EffectMaterialAsset(IAssetRegistry registry, Guid assetId, Info info) : b protected override bool NeedsSecondaryAssets => false; - protected override ValueTask> Load() + protected override IEnumerable Load() { diContainer.TryGetTag(out UniformBuffer fogParams); material = new EffectMaterial(diContainer) @@ -73,8 +72,8 @@ protected override ValueTask> Load() material.FogParams.Buffer = fogParams.Buffer; return textureHandle is null - ? ValueTask.FromResult>([ samplerHandle ]) - : ValueTask.FromResult>([ samplerHandle, textureHandle.Value ]); + ? [ samplerHandle ] + : [ samplerHandle, textureHandle.Value ]; } private AssetHandle? LoadTexture() diff --git a/zzre/assets/ModelMaterialAsset.cs b/zzre/assets/ModelMaterialAsset.cs index 9a54e416..83766828 100644 --- a/zzre/assets/ModelMaterialAsset.cs +++ b/zzre/assets/ModelMaterialAsset.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using Veldrid; using zzio; using zzio.rwbs; @@ -38,7 +37,7 @@ public ModelMaterialAsset(IAssetRegistry registry, Guid assetId, protected override bool NeedsSecondaryAssets => false; - protected override ValueTask> Load() + protected override IEnumerable Load() { material = new ModelMaterial(diContainer); material.DebugName = DebugName; @@ -52,8 +51,8 @@ protected override ValueTask> Load() material.View.BufferRange = camera.ViewRange; return textureHandle is null - ? ValueTask.FromResult>([ samplerHandle ]) - : ValueTask.FromResult>([ samplerHandle, textureHandle.Value ]); + ? [ samplerHandle ] + : [ samplerHandle, textureHandle.Value ]; } private AssetHandle? LoadTexture() diff --git a/zzre/assets/SamplerAsset.cs b/zzre/assets/SamplerAsset.cs index 7c5a9b1a..b2a37511 100644 --- a/zzre/assets/SamplerAsset.cs +++ b/zzre/assets/SamplerAsset.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Text; -using System.Threading.Tasks; using Veldrid; namespace zzre; @@ -35,7 +34,7 @@ public SamplerAsset(IAssetRegistry registry, Guid assetId, SamplerDescription in DebugName = stringBuilder.ToString(); } - protected override ValueTask> Load() + protected override IEnumerable Load() { var resourceFactory = diContainer.GetTag(); sampler = resourceFactory.CreateSampler(info); diff --git a/zzre/assets/SoundAsset.cs b/zzre/assets/SoundAsset.cs index fe92d71e..d2d1d7b7 100644 --- a/zzre/assets/SoundAsset.cs +++ b/zzre/assets/SoundAsset.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Threading.Tasks; using Silk.NET.OpenAL; using Silk.NET.OpenAL.Extensions.EXT; using Silk.NET.SDL; @@ -29,7 +28,7 @@ public SoundAsset(IAssetRegistry registry, Guid assetId, Info info) : base(regis this.info = info; } - protected override ValueTask> Load() + protected override IEnumerable Load() { if (!diContainer.TryGetTag(out OpenALDevice device) || !diContainer.TryGetTag(out SoundContext context)) diff --git a/zzre/assets/TextureAsset.cs b/zzre/assets/TextureAsset.cs index 174069cf..b783f93e 100644 --- a/zzre/assets/TextureAsset.cs +++ b/zzre/assets/TextureAsset.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Threading.Tasks; using Silk.NET.SDL; using Veldrid; using zzio; @@ -32,7 +31,7 @@ public TextureAsset(IAssetRegistry registry, Guid assetId, Info info) : base(reg path = info.FullPath; } - protected override ValueTask> Load() + protected override IEnumerable Load() { var resourcePool = diContainer.GetTag(); using var textureStream = resourcePool.FindAndOpen(path) ?? diff --git a/zzre/assets/UIBitmapAsset.cs b/zzre/assets/UIBitmapAsset.cs index 30b02041..295866b4 100644 --- a/zzre/assets/UIBitmapAsset.cs +++ b/zzre/assets/UIBitmapAsset.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Numerics; -using System.Threading.Tasks; using Silk.NET.SDL; using Veldrid; using zzio; @@ -49,7 +48,7 @@ public UIBitmapAsset(IAssetRegistry registry, Guid assetId, Info info, string? d // - We don't have to wait for samplers protected override bool NeedsSecondaryAssets => false; - protected override ValueTask> Load() + protected override IEnumerable Load() { var graphicsDevice = diContainer.GetTag(); using var bitmap = LoadMaskedBitmap(info.Name); @@ -77,7 +76,7 @@ protected override ValueTask> Load() material.MaskTexture.Texture = maskTexture; } - return ValueTask.FromResult>([ samplerAsset ]); + return [ samplerAsset ]; } protected unsafe SdlSurfacePtr LoadMaskedBitmap(string name) diff --git a/zzre/assets/UIPreloadAsset.cs b/zzre/assets/UIPreloadAsset.cs index bc24bf03..574108e6 100644 --- a/zzre/assets/UIPreloadAsset.cs +++ b/zzre/assets/UIPreloadAsset.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; namespace zzre; @@ -48,7 +47,7 @@ public static void Register() => public UIPreloadAsset(IAssetRegistry registry, Guid assetId, Info _) : base(registry, assetId) { } - protected override async ValueTask> Load() + protected override IEnumerable Load() { AssetHandle[] allAssets = [ @@ -77,9 +76,9 @@ protected override async ValueTask> Load() Preload(out var swt000, Swt000) ]; - await Registry.WaitAsyncAll([fnt000, fnt001, fnt002, fnt003]); + //await Registry.WaitAsyncAll([fnt000, fnt001, fnt002, fnt003]); - await SetAlternatives(target: fnt000, + SetAlternatives(target: fnt000, fnt001, fnt002, fsp000, @@ -90,12 +89,12 @@ await SetAlternatives(target: fnt000, cls001, fnt004); - await SetAlternatives(target: fnt001, + SetAlternatives(target: fnt001, fnt002, inf000, fnt000); - await SetAlternatives(target: fnt002, + SetAlternatives(target: fnt002, fnt001, inf000, fsp000, @@ -103,7 +102,7 @@ await SetAlternatives(target: fnt002, cls000, cls001); - await SetAlternatives(target: fnt003, + SetAlternatives(target: fnt003, fnt001, fnt002, fsp000, @@ -127,8 +126,8 @@ private unsafe AssetHandle Preload(out AssetHandle handle, UIT { var applyConfig = (lineHeight ?? lineOffset ?? charSpacing) is not null; return handle = (applyConfig - ? Registry.Load(info, AssetLoadPriority.High, &ApplyFontConfig, (lineHeight, lineOffset, charSpacing)) - : Registry.Load(info, AssetLoadPriority.High)) + ? Registry.Load(info, AssetLoadPriority.Synchronous, &ApplyFontConfig, (lineHeight, lineOffset, charSpacing)) + : Registry.Load(info, AssetLoadPriority.Synchronous)) .As(); } @@ -144,9 +143,9 @@ private static void ApplyFontConfig(AssetHandle handle, ref readonly (float?, fl tileSheet.CharSpacing = charSpacing.Value; } - private async Task SetAlternatives(AssetHandle target, params AssetHandle[] alternatives) + private static void SetAlternatives(AssetHandle target, params AssetHandle[] alternatives) { - await Registry.WaitAsyncAll(alternatives); + //await Registry.WaitAsyncAll(alternatives); var targetTileSheet = target.Get().TileSheet; foreach (var alternative in alternatives) targetTileSheet.Alternatives.Add(alternative.Get().TileSheet); diff --git a/zzre/assets/UITileSheetAsset.cs b/zzre/assets/UITileSheetAsset.cs index 5923ec9d..d9d1e400 100644 --- a/zzre/assets/UITileSheetAsset.cs +++ b/zzre/assets/UITileSheetAsset.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using Veldrid; using zzre.game; using zzre.materials; @@ -36,7 +35,7 @@ public sealed class UITileSheetAsset : UIBitmapAsset this.info = info; } - protected override ValueTask> Load() + protected override IEnumerable Load() { using var bitmap = LoadMaskedBitmap(info.Name); tileSheet = new TileSheet(info.Name, bitmap, info.IsFont); @@ -65,7 +64,7 @@ protected override ValueTask> Load() material.MainTexture.Texture = texture; material.MainSampler.Sampler = samplerHandle.Get().Sampler; material.ScreenSize.Buffer = diContainer.GetTag().ProjectionBuffer; - return ValueTask.FromResult>([ samplerHandle ]); + return [ samplerHandle ]; } protected override void Unload() diff --git a/zzre/assets/WorldAsset.cs b/zzre/assets/WorldAsset.cs index 4d7540de..d148bbfc 100644 --- a/zzre/assets/WorldAsset.cs +++ b/zzre/assets/WorldAsset.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using zzio; using zzre.rendering; @@ -24,7 +23,7 @@ public WorldAsset(IAssetRegistry registry, Guid assetId, Info info) : base(regis this.info = info; } - protected override ValueTask> Load() + protected override IEnumerable Load() { mesh = new WorldMesh(diContainer, info.FullPath); return NoSecondaryAssets;