diff --git a/My project/Assets/FlowScope/Runtime/Audio/AudioService.cs b/My project/Assets/FlowScope/Runtime/Audio/AudioService.cs index 06e9076..884e467 100644 --- a/My project/Assets/FlowScope/Runtime/Audio/AudioService.cs +++ b/My project/Assets/FlowScope/Runtime/Audio/AudioService.cs @@ -187,14 +187,14 @@ namespace FlowScope.Audio { try { - var task = _resources.LoadAsync(key, CancellationToken.None); - if (!task.IsCompleted) + if (!(_resources is ICompletedResourceService completedResources) || + !completedResources.TryLoadCompleted(key, out IResourceHandle handle)) { throw new InvalidOperationException( - $"Audio clip load is asynchronous: {key}. Use PlayBgmAsync or PlaySfxAsync."); + $"Audio clip is not loaded synchronously: {key}. Use PlayBgmAsync or PlaySfxAsync."); } - return ValidateLoadedClip(key, task.Result); + return ValidateLoadedClip(key, handle); } catch (Exception exception) { diff --git a/My project/Assets/FlowScope/Runtime/Resources/AddressablesResourceService.cs b/My project/Assets/FlowScope/Runtime/Resources/AddressablesResourceService.cs index 532891c..2ba293b 100644 --- a/My project/Assets/FlowScope/Runtime/Resources/AddressablesResourceService.cs +++ b/My project/Assets/FlowScope/Runtime/Resources/AddressablesResourceService.cs @@ -6,11 +6,11 @@ using System.Threading.Tasks; namespace FlowScope.Resources { - public sealed class AddressablesResourceService : IResourceService + public sealed class AddressablesResourceService : IResourceService, ICompletedResourceService { private readonly object _gate = new(); private readonly Dictionary _entries = new(); - private readonly Dictionary> _loads = new(); + private readonly Dictionary _loads = new(); private readonly Func> _loadAssetAsync; private readonly Action _releaseAsset; @@ -39,7 +39,7 @@ namespace FlowScope.Resources cancellationToken.ThrowIfCancellationRequested(); var resourceKey = new ResourceKey(key, typeof(T)); - Task loadTask; + SharedLoad sharedLoad; lock (_gate) { @@ -48,36 +48,64 @@ namespace FlowScope.Resources return entry.CreateHandle(); } - if (!_loads.TryGetValue(resourceKey, out loadTask)) + if (!_loads.TryGetValue(resourceKey, out sharedLoad)) { - loadTask = LoadEntryAsync(key, cancellationToken); - _loads.Add(resourceKey, loadTask); + sharedLoad = new SharedLoad(); + _loads.Add(resourceKey, sharedLoad); + } + + sharedLoad.WaiterCount++; + if (sharedLoad.LoadTask == null) + { + sharedLoad.LoadTask = LoadAndOwnEntryAsync(resourceKey, key, sharedLoad); } } try { - var loaded = await loadTask; + var loaded = await WaitForSharedLoad(sharedLoad.LoadTask, cancellationToken); lock (_gate) { - if (!_entries.ContainsKey(resourceKey)) - { - _entries.Add(resourceKey, loaded); - } - - _loads.Remove(resourceKey); - return _entries[resourceKey].CreateHandle(); + return loaded.CreateHandle(); } } - catch + catch (OperationCanceledException) { lock (_gate) { - _loads.Remove(resourceKey); + if (sharedLoad.WaiterCount > 0) + { + sharedLoad.WaiterCount--; + } } throw; } + catch + { + throw; + } + } + + public bool TryLoadCompleted(string key, out IResourceHandle handle) + where T : class + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("Resource key is required.", nameof(key)); + } + + lock (_gate) + { + if (_entries.TryGetValue(new ResourceKey(key, typeof(T)), out var entry)) + { + handle = entry.CreateHandle(); + return true; + } + } + + handle = null; + return false; } public IResourceGroup CreateGroup() @@ -113,6 +141,61 @@ namespace FlowScope.Resources return new ResourceEntry(key, typeof(T), asset, ReleaseEntry); } + private async Task LoadAndOwnEntryAsync( + ResourceKey resourceKey, + string key, + SharedLoad sharedLoad) + where T : class + { + ResourceEntry entry = null; + try + { + entry = await LoadEntryAsync(key, CancellationToken.None); + lock (_gate) + { + _loads.Remove(resourceKey); + if (sharedLoad.WaiterCount > 0) + { + _entries.Add(resourceKey, entry); + return entry; + } + } + + ReleaseEntry(entry.Key, entry.AssetType, entry.Asset); + return entry; + } + catch + { + lock (_gate) + { + _loads.Remove(resourceKey); + } + + throw; + } + } + + private static async Task WaitForSharedLoad( + Task loadTask, + CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + { + return await loadTask; + } + + var cancellation = new TaskCompletionSource(); + using (cancellationToken.Register(() => cancellation.TrySetResult(true))) + { + if (await Task.WhenAny(loadTask, cancellation.Task) == cancellation.Task) + { + throw new OperationCanceledException(cancellationToken); + } + } + + return await loadTask; + } + private void ReleaseEntry(string key, Type assetType, object asset) { lock (_gate) @@ -123,6 +206,12 @@ namespace FlowScope.Resources _releaseAsset(key, asset); } + private sealed class SharedLoad + { + public Task LoadTask; + public int WaiterCount; + } + private readonly struct ResourceKey : IEquatable { private readonly string _key; diff --git a/My project/Assets/FlowScope/Runtime/Resources/IResourceService.cs b/My project/Assets/FlowScope/Runtime/Resources/IResourceService.cs index 8b026ae..93f5478 100644 --- a/My project/Assets/FlowScope/Runtime/Resources/IResourceService.cs +++ b/My project/Assets/FlowScope/Runtime/Resources/IResourceService.cs @@ -12,4 +12,10 @@ namespace FlowScope.Resources IResourceGroup CreateGroup(); } + + public interface ICompletedResourceService + { + bool TryLoadCompleted(string key, out IResourceHandle handle) + where T : class; + } } diff --git a/My project/Assets/FlowScope/Tests/PlayMode/Audio/AudioServiceTests.cs b/My project/Assets/FlowScope/Tests/PlayMode/Audio/AudioServiceTests.cs index ce72269..7ddc1b8 100644 --- a/My project/Assets/FlowScope/Tests/PlayMode/Audio/AudioServiceTests.cs +++ b/My project/Assets/FlowScope/Tests/PlayMode/Audio/AudioServiceTests.cs @@ -172,6 +172,7 @@ namespace FlowScope.Tests.PlayMode.Audio var exception = Assert.Throws(() => service.PlayBgm("slow-bgm")); Assert.That(exception.Message, Does.Contain("slow-bgm")); + Assert.That(resources.LoadRequests, Is.EqualTo(0)); Assert.That(Object.FindObjectsOfType().Length, Is.EqualTo(sourceCountBeforePlay)); pending.SetResult(bgmA); } @@ -186,6 +187,7 @@ namespace FlowScope.Tests.PlayMode.Audio var exception = Assert.Throws(() => service.PlaySfx("slow-sfx")); Assert.That(exception.Message, Does.Contain("slow-sfx")); + Assert.That(resources.LoadRequests, Is.EqualTo(0)); Assert.That(Object.FindObjectsOfType().Length, Is.EqualTo(sourceCountBeforePlay)); pending.SetResult(sfx); } @@ -261,10 +263,11 @@ namespace FlowScope.Tests.PlayMode.Audio return clip; } - private sealed class FakeResourceService : IResourceService + private sealed class FakeResourceService : IResourceService, ICompletedResourceService { private readonly Dictionary clips = new(); private readonly Dictionary> pendingClips = new(); + public int LoadRequests { get; private set; } public void Add(string key, AudioClip clip) { @@ -281,6 +284,7 @@ namespace FlowScope.Tests.PlayMode.Audio public async Task> LoadAsync(string key, CancellationToken cancellationToken) where T : class { + LoadRequests++; if (typeof(T) != typeof(AudioClip)) { throw new InvalidOperationException($"missing resource: {key}"); @@ -299,6 +303,19 @@ namespace FlowScope.Tests.PlayMode.Audio return new FakeResourceHandle(key, clip as T); } + public bool TryLoadCompleted(string key, out IResourceHandle handle) + where T : class + { + if (typeof(T) == typeof(AudioClip) && clips.TryGetValue(key, out var clip)) + { + handle = new FakeResourceHandle(key, clip as T); + return true; + } + + handle = null; + return false; + } + public IResourceGroup CreateGroup() { throw new NotSupportedException(); diff --git a/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceServiceTests.cs b/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceServiceTests.cs index a9d630b..7acb1c3 100644 --- a/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceServiceTests.cs +++ b/My project/Assets/FlowScope/Tests/PlayMode/Resources/AddressablesResourceServiceTests.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using FlowScope.Resources; using NUnit.Framework; +using UnityEngine; using UnityEngine.TestTools; namespace FlowScope.Tests.PlayMode.Resources @@ -58,6 +59,99 @@ namespace FlowScope.Tests.PlayMode.Resources StringAssert.Contains("Addressables", exception.Message); } + [UnityTest] + public IEnumerator LoadAsync_WhenOneConcurrentCallerCancels_OtherCallerStillReceivesSharedLoad() + { + var backendLoads = 0; + var gate = new TaskCompletionSource(); + var service = new AddressablesResourceService( + async (_, _, cancellationToken) => + { + backendLoads++; + Assert.That(cancellationToken.CanBeCanceled, Is.False); + return await gate.Task; + }, + (_, _) => { }); + using var cts = new CancellationTokenSource(); + + var canceled = service.LoadAsync("shared", cts.Token); + var survivor = service.LoadAsync("shared", CancellationToken.None); + cts.Cancel(); + + yield return ThrowsAsync(canceled); + gate.SetResult(new TestAsset()); + IResourceHandle survivorHandle = null; + yield return AwaitTask(survivor, handle => survivorHandle = handle); + + Assert.AreEqual(1, backendLoads); + Assert.IsNotNull(survivorHandle.Asset); + survivorHandle.Dispose(); + } + + [UnityTest] + public IEnumerator LoadAsync_WhenOnlyCallerCancels_ReleasesCompletedBackendAsset() + { + var backendLoads = 0; + var releaseCount = 0; + var gate = new TaskCompletionSource(); + var service = new AddressablesResourceService( + async (_, _, cancellationToken) => + { + backendLoads++; + Assert.That(cancellationToken.CanBeCanceled, Is.False); + if (backendLoads == 1) + { + return await gate.Task; + } + + return new TestAsset(); + }, + (_, _) => releaseCount++); + using var cts = new CancellationTokenSource(); + + var canceled = service.LoadAsync("orphan", cts.Token); + cts.Cancel(); + yield return ThrowsAsync(canceled); + gate.SetResult(new TestAsset()); + + yield return new WaitUntil(() => releaseCount == 1); + Assert.IsFalse(service.TryLoadCompleted("orphan", out _)); + + IResourceHandle nextHandle = null; + yield return AwaitTask( + service.LoadAsync("orphan", CancellationToken.None), + handle => nextHandle = handle); + + Assert.AreEqual(2, backendLoads); + Assert.IsNotNull(nextHandle.Asset); + nextHandle.Dispose(); + Assert.AreEqual(2, releaseCount); + } + + [UnityTest] + public IEnumerator TryLoadCompleted_AfterAsyncLoad_ReturnsCachedHandleWithoutBackendLoad() + { + var backendLoads = 0; + var service = new AddressablesResourceService( + (_, _, _) => + { + backendLoads++; + return Task.FromResult(new TestAsset()); + }, + (_, _) => { }); + + IResourceHandle first = null; + yield return AwaitTask( + service.LoadAsync("cached", CancellationToken.None), + handle => first = handle); + Assert.IsTrue(service.TryLoadCompleted("cached", out var second)); + + Assert.AreEqual(1, backendLoads); + Assert.AreSame(first.Asset, second.Asset); + first.Dispose(); + second.Dispose(); + } + private static IEnumerator ThrowsAsync(Func action, Action onCaught) where TException : Exception { @@ -76,6 +170,27 @@ namespace FlowScope.Tests.PlayMode.Resources Assert.Fail($"Expected {typeof(TException).Name}."); } + private static IEnumerator ThrowsAsync(Task task) + where TException : Exception + { + while (!task.IsCompleted) + { + yield return null; + } + + if (task.IsFaulted && task.Exception?.GetBaseException() is TException) + { + yield break; + } + + if (task.IsCanceled && typeof(TException) == typeof(OperationCanceledException)) + { + yield break; + } + + Assert.Fail($"Expected {typeof(TException).Name}."); + } + private static IEnumerator AwaitTask(Task task, Action onCompleted) { while (!task.IsCompleted)