Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using CommunicationData.URLHelpers;
using Cysharp.Threading.Tasks;
using DCL.AvatarRendering.Loading.Exceptions;
using DCL.Ipfs;
using ECS.StreamableLoading.Common.Components;
using ECS.StreamableLoading.Textures;
Expand Down Expand Up @@ -55,7 +56,12 @@ async UniTask<Sprite> WaitForThumbnailAsync(int checkInterval, CancellationToken
do await UniTask.Delay(checkInterval, cancellationToken: ct);
while (ThumbnailAssetResult is not { IsInitialized: true });

return ThumbnailAssetResult!.Value.Asset;
StreamableLoadingResult<SpriteData>.WithFallback result = ThumbnailAssetResult!.Value;

if (!result.Succeeded)
throw new ThumbnailLoadFailedException();

return result.Asset;
}
}
}
8 changes: 8 additions & 0 deletions Explorer/Assets/DCL/AvatarRendering/Loading/Exceptions.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System;

namespace DCL.AvatarRendering.Loading.Exceptions
{
/// <summary>
/// Thrown when a thumbnail request reaches a terminal failure state (e.g. the underlying
/// promise was cancelled mid-flight). Distinct from <see cref="OperationCanceledException"/>,
/// which is reserved for caller-initiated cancellation.
/// </summary>
public class ThumbnailLoadFailedException : Exception
{
public ThumbnailLoadFailedException() : base("Thumbnail load failed or was cancelled before completion") { }

public ThumbnailLoadFailedException(string message) : base(message) { }
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using Arch.Core;
using Cysharp.Threading.Tasks;
using DCL.AvatarRendering.Loading.Components;
using ECS;
using ECS.Prioritization.Components;
using System.Threading;
using DCL.AvatarRendering.Loading.Exceptions;
using DCL.AvatarRendering.Thumbnails.Utils;
using DCL.Multiplayer.Connections.DecentralandUrls;
using ECS.Prioritization.Components;
using ECS.StreamableLoading.Common.Components;
using ECS.StreamableLoading.Textures;
using System;
using System.Threading;
using UnityEngine;

namespace DCL.AvatarRendering.Wearables
Expand All @@ -21,21 +24,50 @@ public ECSThumbnailProvider(IDecentralandUrlsSource urlsSource, World world)
this.world = world;
}

public async UniTask<Sprite> GetAsync(IThumbnailAttachment avatarAttachment, CancellationToken ct)
public async UniTask<Sprite> GetAsync(IThumbnailAttachment avatarAttachment, CancellationToken ct, int timeoutMs = IThumbnailProvider.DEFAULT_TIMEOUT_MS)
{
if (avatarAttachment.ThumbnailAssetResult is { IsInitialized: true })
return avatarAttachment.ThumbnailAssetResult.Value.Asset;
if (avatarAttachment.ThumbnailAssetResult is { IsInitialized: true } existing)
{
if (existing.Succeeded)
return existing.Asset;

// A previous attempt was cancelled (e.g. page change). Clear so a fresh promise
// can spawn below. Sticky failures keep the slot and fall through to throw.
if (existing.Cancelled)
avatarAttachment.ThumbnailAssetResult = null;
else
throw new ThumbnailLoadFailedException();
}

CancellationTokenSource promiseCts = CancellationTokenSource.CreateLinkedTokenSource(ct);

LoadThumbnailsUtils.CreateThumbnailABPromise(
urlsSource,
avatarAttachment,
world,
PartitionComponent.TOP_PRIORITY,
CancellationTokenSource.CreateLinkedTokenSource(ct));
promiseCts);

using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeoutMs);

try
{
// We dont create an async task from the promise since it needs to be consumed at the proper system, not here
// The promise's result will eventually get replicated into the avatar attachment
return await avatarAttachment.WaitForThumbnailAsync(0, timeoutCts.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Timed out: cancel the underlying promise so the resolver cleans it up, and record
// a sticky Failed on the attachment so subsequent calls don't immediately re-attempt
// a load that we already gave up on. (The resolver will see IsCancellationRequested
// but skip overwriting because the slot is now Failed, not Cancelled.)
promiseCts.Cancel();
avatarAttachment.ThumbnailAssetResult = StreamableLoadingResult<SpriteData>.WithFallback.Failed();

// We dont create an async task from the promise since it needs to be consumed at the proper system, not here
// The promise's result will eventually get replicated into the avatar attachment
return await avatarAttachment.WaitForThumbnailAsync(0, ct);
throw new ThumbnailLoadFailedException($"Thumbnail load timed out after {timeoutMs}ms");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ namespace DCL.AvatarRendering.Wearables
{
public interface IThumbnailProvider
{
UniTask<Sprite> GetAsync(IThumbnailAttachment avatarAttachment, CancellationToken ct);
/// <summary>
/// Bounds the wait on a thumbnail load to a sensible UX budget, so requests that stall
/// in the streaming pipeline (e.g. budget starvation, lost promises, curl aborts)
/// do not leave callers spinning indefinitely.
/// </summary>
public const int DEFAULT_TIMEOUT_MS = 30_000;

UniTask<Sprite> GetAsync(IThumbnailAttachment avatarAttachment, CancellationToken ct, int timeoutMs = DEFAULT_TIMEOUT_MS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ private void CompleteWearableABThumbnailDownload(Entity entity, ref IThumbnailAt
{
if (promise.IsCancellationRequested(World))
{
wearable.ThumbnailAssetResult = null;
// Mark as Cancelled so the next GetAsync call clears the slot and retries.
// Mark as Cancelled so the next GetAsync call clears the slot and retries.
// Don't overwrite a sticky Failed (e.g. from a consumer timeout); only reset to
// Cancelled when the slot is clear, successful, or already Cancelled.
if (wearable.ThumbnailAssetResult is not { IsInitialized: true } existing
|| existing.Succeeded || existing.Cancelled)
wearable.ThumbnailAssetResult = StreamableLoadingResult<SpriteData>.WithFallback.CancelledResult();
World.Destroy(entity);
return;
}
Expand All @@ -51,7 +57,12 @@ private void CompleteWearableThumbnailDownload(Entity entity, ref IThumbnailAtta
{
if (promise.IsCancellationRequested(World))
{
wearable.ThumbnailAssetResult = null;
// Mark as Cancelled so the next GetAsync call clears the slot and retries.
Comment thread
lorux0 marked this conversation as resolved.
// Don't overwrite a sticky Failed (e.g. from a consumer timeout).
if (wearable.ThumbnailAssetResult is not { IsInitialized: true } existing
|| existing.Succeeded || existing.Cancelled)
wearable.ThumbnailAssetResult = StreamableLoadingResult<SpriteData>.WithFallback.CancelledResult();
wearable.ThumbnailAssetResult = StreamableLoadingResult<SpriteData>.WithFallback.CancelledResult();
Comment thread
lorux0 marked this conversation as resolved.
Outdated
World.Destroy(entity);
return;
}
Expand Down
27 changes: 22 additions & 5 deletions Explorer/Assets/DCL/Backpack/BackpackGridController.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using CommunicationData.URLHelpers;
using Cysharp.Threading.Tasks;
using DCL.AssetsProvision;
using DCL.AvatarRendering.Thumbnails.Utils;
using DCL.AvatarRendering.Wearables;
using DCL.AvatarRendering.Wearables.Components;
using DCL.AvatarRendering.Wearables.Equipped;
Expand Down Expand Up @@ -409,13 +410,29 @@ private async UniTaskVoid AwaitWearablesPromiseAsync(int pageNumber, bool refres

private async UniTaskVoid InitializeItemViewAsync(ITrimmedWearable itemWearable, BackpackItemView itemView, CancellationToken ct)
{
Sprite sprite = await thumbnailProvider.GetAsync(itemWearable, ct);
if (ct.IsCancellationRequested) return;
try
{
Sprite sprite = await thumbnailProvider.GetAsync(itemWearable, ct);
if (ct.IsCancellationRequested) return;

itemView.WearableThumbnail.sprite = sprite;
itemView.LoadingView.FinishLoadingAnimation(itemView.FullBackpackItem);

itemView.SmartWearableBadgeContainer.SetActive(itemWearable.IsSmart());
}
catch (OperationCanceledException) { }
catch (Exception e)
{
ReportHub.LogException(e, new ReportData(ReportCategory.THUMBNAILS));

itemView.WearableThumbnail.sprite = sprite;
itemView.LoadingView.FinishLoadingAnimation(itemView.FullBackpackItem);
// Failure path must still unblock the cell, otherwise it sits in the "loading"
// state forever and the surrounding grid input stays gated.
if (ct.IsCancellationRequested) return;

itemView.SmartWearableBadgeContainer.SetActive(itemWearable.IsSmart());
itemView.WearableThumbnail.sprite = LoadThumbnailsUtils.DEFAULT_THUMBNAIL.Sprite;
itemView.LoadingView.FinishLoadingAnimation(itemView.FullBackpackItem);
itemView.SmartWearableBadgeContainer.SetActive(itemWearable.IsSmart());
}
}

private void ClearPoolElements()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using DCL.AvatarRendering.Emotes;
using DCL.AvatarRendering.Emotes.Equipped;
using DCL.AvatarRendering.Loading.Components;
using DCL.AvatarRendering.Thumbnails.Utils;
using DCL.AvatarRendering.Wearables;
using DCL.AvatarRendering.Wearables.Components;
using DCL.Backpack.BackpackBus;
Expand Down Expand Up @@ -360,12 +361,27 @@ private void OnCollectiblesOnlyChanged(bool collectiblesOnly)

private async UniTaskVoid WaitForThumbnailAsync(IThumbnailAttachment emote, BackpackItemView itemView, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
try
{
Sprite sprite = await thumbnailProvider.GetAsync(emote, ct);

if (ct.IsCancellationRequested) return;

itemView.WearableThumbnail.sprite = sprite;
itemView.LoadingView.FinishLoadingAnimation(itemView.FullBackpackItem);
}
catch (OperationCanceledException) { }
catch (Exception e)
{
ReportHub.LogException(e, new ReportData(ReportCategory.THUMBNAILS));

Sprite sprite = await thumbnailProvider.GetAsync(emote, ct);
// Failure path must still unblock the cell, otherwise it sits in the "loading"
// state forever and the surrounding grid input stays gated.
if (ct.IsCancellationRequested) return;

itemView.WearableThumbnail.sprite = sprite;
itemView.LoadingView.FinishLoadingAnimation(itemView.FullBackpackItem);
itemView.WearableThumbnail.sprite = LoadThumbnailsUtils.DEFAULT_THUMBNAIL.Sprite;
itemView.LoadingView.FinishLoadingAnimation(itemView.FullBackpackItem);
}
}

private void ClearPoolElements()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,48 @@ public readonly struct WithFallback
/// </summary>
private readonly bool initialized;

/// <summary>
/// True when the request reached a terminal successful (or fallback-acceptable) state.
/// False when the request failed or was cancelled; check <see cref="Cancelled"/>
/// to distinguish a transient cancellation from a sticky failure.
/// </summary>
public readonly bool Succeeded;

/// <summary>
/// True when the request was cancelled mid-flight (transient state — consumers can
/// clear the slot and retry). False for both genuine successes and sticky failures.
/// </summary>
public readonly bool Cancelled;

public WithFallback(T asset)
{
Asset = asset;
initialized = true;
Succeeded = true;
Cancelled = false;
}

private WithFallback(T asset, bool initialized, bool succeeded, bool cancelled)
{
Asset = asset;
this.initialized = initialized;
Succeeded = succeeded;
Cancelled = cancelled;
}

public static WithFallback Failed() =>
new (default!, initialized: true, succeeded: false, cancelled: false);

public static WithFallback CancelledResult() =>
new (default!, initialized: true, succeeded: false, cancelled: true);

/// <summary>
/// Can be uninitialized if structure was created with default constructor
/// </summary>
public bool IsInitialized => initialized;

public static implicit operator StreamableLoadingResult<T>(WithFallback withFallback) =>
withFallback.IsInitialized ? new StreamableLoadingResult<T>(withFallback.Asset) : new StreamableLoadingResult<T>();
withFallback.IsInitialized && withFallback.Succeeded ? new StreamableLoadingResult<T>(withFallback.Asset) : new StreamableLoadingResult<T>();
}

private readonly (ReportData reportData, Exception exception)? exceptionData;
Expand Down
Loading