diff --git a/Explorer/Assets/DCL/Backpack/SmartWearableAuthorizationPopupController.cs b/Explorer/Assets/DCL/Backpack/SmartWearableAuthorizationPopupController.cs index 4c6e938ba99..47cead399cd 100644 --- a/Explorer/Assets/DCL/Backpack/SmartWearableAuthorizationPopupController.cs +++ b/Explorer/Assets/DCL/Backpack/SmartWearableAuthorizationPopupController.cs @@ -1,13 +1,9 @@ using Cysharp.Threading.Tasks; -using DCL.AvatarRendering.Loading.Components; -using DCL.AvatarRendering.Thumbnails.Utils; using DCL.AvatarRendering.Wearables.Components; using DCL.Backpack; using DCL.Ipfs; -using JetBrains.Annotations; using MVC; using System.Threading; -using UnityEngine; namespace Runtime.Wearables { @@ -19,7 +15,7 @@ public class SmartWearableAuthorizationPopupController : ControllerBase RequestAuthorizationAsync(IMVCManager mvcManager, IWearable wearable, CancellationToken ct) diff --git a/Explorer/Assets/DCL/Backpack/SmartWearableAuthorizationPopupView.cs b/Explorer/Assets/DCL/Backpack/SmartWearableAuthorizationPopupView.cs index 5103b9da2d2..efe295c02a5 100644 --- a/Explorer/Assets/DCL/Backpack/SmartWearableAuthorizationPopupView.cs +++ b/Explorer/Assets/DCL/Backpack/SmartWearableAuthorizationPopupView.cs @@ -53,7 +53,7 @@ public async UniTask WaitChoiceAsync() await UniTask.WhenAny(AuthorizeButton.OnClickAsync(), DenyButton.OnClickAsync()); } - public void Setup(string wearableName, Sprite thumbnail, Sprite rarityBackground, Color rarityColor, Sprite categoryIcon) + public void Setup(string wearableName, Sprite? thumbnail, Sprite rarityBackground, Color rarityColor, Sprite categoryIcon) { PromptText.text = string.Format(PromptFormat, wearableName); WearableThumbnail.sprite = thumbnail; diff --git a/Explorer/Assets/DCL/Chat/_Refactor/ChatServices/UserStateService/PrivateConversationUserStateService.cs b/Explorer/Assets/DCL/Chat/_Refactor/ChatServices/UserStateService/PrivateConversationUserStateService.cs index e778a947217..98f0452aadc 100644 --- a/Explorer/Assets/DCL/Chat/_Refactor/ChatServices/UserStateService/PrivateConversationUserStateService.cs +++ b/Explorer/Assets/DCL/Chat/_Refactor/ChatServices/UserStateService/PrivateConversationUserStateService.cs @@ -1,6 +1,7 @@ using Cysharp.Threading.Tasks; using DCL.Chat.History; using DCL.Diagnostics; +using DCL.FeatureFlags; using DCL.Friends; using DCL.Friends.UserBlocking; using DCL.Multiplayer.Connections.RoomHubs; @@ -113,6 +114,10 @@ public async UniTask InitializeAsync(CancellationToken ct) { await rpcChatPrivacyService.GetOwnSocialSettingsAsync(cts.Token); + // The chat room is never connected in local scene development (see CommsContainer); waiting would just burn the timeout. + if (FeaturesRegistry.Instance.IsEnabled(FeatureId.LocalSceneDevelopment)) + return; + await UniTask.WaitUntil(() => chatRoom.Info.ConnectionState == LKConnectionState.ConnConnected, cancellationToken: cts.Token) .Timeout(TimeSpan.FromMinutes(TIMEOUT_FRIENDS_CONTAINER_MINUTES)); diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs index ec26341ac5f..569006dbf79 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs @@ -165,7 +165,9 @@ public static CommsContainer Create( roomHub = new RoomHub( localSceneDevelopment ? IConnectiveRoom.Null.INSTANCE : archipelagoIslandRoom, gateKeeperSceneRoom, - chatRoom, + + // Friends is disabled in local scene development, so DMs delivered over the chat room would be unanswerable + localSceneDevelopment ? IConnectiveRoom.Null.INSTANCE : chatRoom, voiceChatRoom ); } diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs index 952dea52016..d61aef91614 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs @@ -825,6 +825,8 @@ await MapRendererContainer uiShellContainer.MvcManager, bootstrapContainer.DecentralandUrlsSource)); + globalPlugins.Add(new AnalyticsDiskFullPopupPlugin(bootstrapContainer.Analytics.EventBus, uiShellContainer.MvcManager)); + // ReSharper disable once MethodHasAsyncOverloadWithCancellation if (FeaturesRegistry.Instance.IsEnabled(FeatureId.VoiceChat)) globalPlugins.Add( diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Apis/Modules/Ethereums/EthereumApiWrapper.cs b/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Apis/Modules/Ethereums/EthereumApiWrapper.cs index 997b946efc4..a2f147acc2a 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Apis/Modules/Ethereums/EthereumApiWrapper.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Apis/Modules/Ethereums/EthereumApiWrapper.cs @@ -14,6 +14,9 @@ namespace SceneRuntime.Apis.Modules.Ethereums { public class EthereumApiWrapper : JsApiWrapper { + // JSON-RPC 2.0 "Method not found": https://www.jsonrpc.org/specification#error_object + private const long JSON_RPC_METHOD_NOT_FOUND = -32601; + private readonly IEthereumApi ethereumApi; private readonly ISceneExceptionsHandler sceneExceptionsHandler; private readonly IWeb3IdentityCache web3IdentityCache; @@ -76,7 +79,7 @@ async UniTask RequestPersonalSignatureAsync(CancellationTok }, }, Web3RequestSource.SdkScene, ct); - return new SignMessageResponse(hex, message, (string)response.result); + return new SignMessageResponse(hex, message, (string?)response.result ?? string.Empty); } catch (Exception e) { @@ -91,10 +94,10 @@ async UniTask RequestPersonalSignatureAsync(CancellationTok [PublicAPI("Used by StreamingAssets/Js/Modules/EthereumController.js")] public object SendAsync(double id, string method, string jsonParams) { - return SendAndFormatAsync(id, method, JsonConvert.DeserializeObject(jsonParams) ?? Array.Empty(), sendCancellationToken.Token) + return SendAndFormatAsync(JsonConvert.DeserializeObject(jsonParams) ?? Array.Empty(), sendCancellationToken.Token) .ToDisconnectedPromise(this); - async UniTask SendAndFormatAsync(double id, string method, object[] @params, CancellationToken ct) + async UniTask SendAndFormatAsync(object[] @params, CancellationToken ct) { try { @@ -110,6 +113,24 @@ async UniTask SendAndFormatAsync(double id, string jsonAnyResponse = JsonConvert.SerializeObject(result), }; } + catch (Web3MethodNotAllowedException e) + { + // Not an engine fault: OnEngineException would report it to Sentry and can suspend the scene on repeated calls + return new SendEthereumMessageResponse + { + jsonAnyResponse = JsonConvert.SerializeObject(new EthApiResponse + { + id = (long)id, + jsonrpc = "2.0", + result = null, + error = new EthApiError + { + code = JSON_RPC_METHOD_NOT_FOUND, + message = e.Message, + }, + }), + }; + } catch (Exception e) { sceneExceptionsHandler.OnEngineException(e); diff --git a/Explorer/Assets/DCL/Infrastructure/Utility/EventBus/AnalyticsDiskFullDetected.cs b/Explorer/Assets/DCL/Infrastructure/Utility/EventBus/AnalyticsDiskFullDetected.cs new file mode 100644 index 00000000000..71f2365e78e --- /dev/null +++ b/Explorer/Assets/DCL/Infrastructure/Utility/EventBus/AnalyticsDiskFullDetected.cs @@ -0,0 +1,5 @@ +namespace Utility +{ + /// Lives in Utility rather than DCL.Analytics so UI-layer subscribers need no dependency on the analytics assembly. + public readonly struct AnalyticsDiskFullDetected { } +} diff --git a/Explorer/Assets/DCL/Infrastructure/Utility/EventBus/AnalyticsDiskFullDetected.cs.meta b/Explorer/Assets/DCL/Infrastructure/Utility/EventBus/AnalyticsDiskFullDetected.cs.meta new file mode 100644 index 00000000000..6bff6d7b454 --- /dev/null +++ b/Explorer/Assets/DCL/Infrastructure/Utility/EventBus/AnalyticsDiskFullDetected.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1808ba520d28499da76194065daea253 diff --git a/Explorer/Assets/DCL/PerformanceAndDiagnostics/Analytics/Systems/AnalyticsContainer.cs b/Explorer/Assets/DCL/PerformanceAndDiagnostics/Analytics/Systems/AnalyticsContainer.cs index c95c5d9a29f..f363f4b6321 100644 --- a/Explorer/Assets/DCL/PerformanceAndDiagnostics/Analytics/Systems/AnalyticsContainer.cs +++ b/Explorer/Assets/DCL/PerformanceAndDiagnostics/Analytics/Systems/AnalyticsContainer.cs @@ -11,6 +11,7 @@ using System; using System.Threading; using UnityEngine; +using Utility; namespace DCL.PerformanceAndDiagnostics.Analytics { @@ -25,6 +26,9 @@ public class AnalyticsContainer : DCLGlobalContainerDecouples analytics-originated events from their UI subscribers, which are created long after this container. + public IEventBus EventBus { get; } = new EventBus(invokeSubscribersOnMainThread: true); + public static async UniTask CreateAsync( IAppArgs appArgs, IWeb3IdentityCache identityCache, @@ -48,6 +52,7 @@ await container.InitializeContainerAsync(settingsC launcherTraits, appArgs, realmLaunchSettings.CurrentMode is LaunchMode.LocalSceneDevelopment, + container.EventBus, ct); var analyticsController = new AnalyticsController(service, appArgs, container.settings.AnalyticsConfig, launcherTraits, installSource, dclVersion, identityCache?.Identity); @@ -66,26 +71,26 @@ await container.InitializeContainerAsync(settingsC return container; } - private static IAnalyticsService CreateAnalyticsService(AnalyticsConfiguration analyticsConfig, LauncherTraits launcherTraits, IAppArgs args, bool isLocalSceneDevelopment, CancellationToken token) + private static IAnalyticsService CreateAnalyticsService(AnalyticsConfiguration analyticsConfig, LauncherTraits launcherTraits, IAppArgs args, bool isLocalSceneDevelopment, IEventBus eventBus, CancellationToken token) { // Avoid Segment analytics for: Unity Editor or Debug Mode (except when in Local Scene Development mode) if (!Application.isEditor && (!args.HasDebugFlag() || isLocalSceneDevelopment)) - return CreateSegmentAnalyticsOrFallbackToDebug(analyticsConfig, launcherTraits, token); + return CreateSegmentAnalyticsOrFallbackToDebug(analyticsConfig, launcherTraits, eventBus, token); return analyticsConfig.Mode switch { - AnalyticsMode.Segment => CreateSegmentAnalyticsOrFallbackToDebug(analyticsConfig, launcherTraits, token), + AnalyticsMode.Segment => CreateSegmentAnalyticsOrFallbackToDebug(analyticsConfig, launcherTraits, eventBus, token), AnalyticsMode.DebugLog => new DebugAnalyticsService(), AnalyticsMode.Disabled => throw new InvalidOperationException("Trying to create analytics when it is disabled"), _ => throw new ArgumentOutOfRangeException(), }; } - private static IAnalyticsService CreateSegmentAnalyticsOrFallbackToDebug(AnalyticsConfiguration analyticsConfig, LauncherTraits launcherTraits, CancellationToken token) + private static IAnalyticsService CreateSegmentAnalyticsOrFallbackToDebug(AnalyticsConfiguration analyticsConfig, LauncherTraits launcherTraits, IEventBus eventBus, CancellationToken token) { if (analyticsConfig.TryGetSegmentConfiguration(out Configuration segmentConfiguration)) - return new RustSegmentAnalyticsService(segmentConfiguration.WriteKey!, launcherTraits.LauncherAnonymousId) + return new RustSegmentAnalyticsService(segmentConfiguration.WriteKey!, launcherTraits.LauncherAnonymousId, eventBus) .WithTimeFlush(TimeSpan.FromSeconds(analyticsConfig.FlushInterval), token); // Fall back to debug if segment is not configured diff --git a/Explorer/Assets/DCL/Places/PlacesFilterSelectorView.cs b/Explorer/Assets/DCL/Places/PlacesFilterSelectorView.cs index e7fb3742654..f368dbe4d3f 100644 --- a/Explorer/Assets/DCL/Places/PlacesFilterSelectorView.cs +++ b/Explorer/Assets/DCL/Places/PlacesFilterSelectorView.cs @@ -45,6 +45,7 @@ public void ResetFilters(bool invokeEvents = true) { sortContentDeselectable.gameObject.SetActive(true); + // The default sort toggle must match PlacesView.ResetCurrentFilters if (invokeEvents) { sortByMostActive.isOn = true; diff --git a/Explorer/Assets/DCL/PluginSystem/Global/AnalyticsDiskFullPopupPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/AnalyticsDiskFullPopupPlugin.cs new file mode 100644 index 00000000000..fa5eab51db3 --- /dev/null +++ b/Explorer/Assets/DCL/PluginSystem/Global/AnalyticsDiskFullPopupPlugin.cs @@ -0,0 +1,70 @@ +using Arch.SystemGroups; +using Cysharp.Threading.Tasks; +using DCL.Diagnostics; +using DCL.UI; +using DCL.UI.ErrorPopup; +using DCL.Utilities.Extensions; +using DCL.Utility.Types; +using MVC; +using System; +using System.Threading; +using UnityEngine; +using Utility; + +namespace DCL.PluginSystem.Global +{ + public class AnalyticsDiskFullPopupPlugin : IDCLGlobalPluginWithoutSettings + { + private readonly IEventBus analyticsEventBus; + private readonly IMVCManager mvcManager; + private readonly CancellationTokenSource cts = new (); + private IDisposable? subscription; + + // The event repeats with every failing flush; the popup must show once per session + private bool popupShown; + + public AnalyticsDiskFullPopupPlugin(IEventBus analyticsEventBus, IMVCManager mvcManager) + { + this.analyticsEventBus = analyticsEventBus; + this.mvcManager = mvcManager; + } + + public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in GlobalPluginArguments arguments) { } + + public UniTask InitializeAsync(NoExposedPluginSettings settings, CancellationToken ct) + { + subscription = analyticsEventBus.Subscribe(OnDiskFull); + return UniTask.CompletedTask; + } + + private void OnDiskFull(AnalyticsDiskFullDetected evt) + { + if (popupShown) + return; + + popupShown = true; + ShowPopupAsync(cts.Token).Forget(); + } + + private async UniTaskVoid ShowPopupAsync(CancellationToken ct) + { + var data = new ErrorPopupData( + UIProperty.UseDefault, + UIProperty.From("Storage Full"), + UIProperty.From("Your device is running out of disk space. Free up space to keep Decentraland working correctly.")); + + EnumResult result = await mvcManager.ShowAsync(new ShowCommand(data), ct) + .SuppressToResultAsync(ReportCategory.ANALYTICS); + + // A show that failed never reached the user, so the next disk-full event may retry + if (result.Error is { State: not TaskError.Cancelled }) + popupShown = false; + } + + public void Dispose() + { + subscription?.Dispose(); + cts.SafeCancelAndDispose(); + } + } +} diff --git a/Explorer/Assets/DCL/PluginSystem/Global/AnalyticsDiskFullPopupPlugin.cs.meta b/Explorer/Assets/DCL/PluginSystem/Global/AnalyticsDiskFullPopupPlugin.cs.meta new file mode 100644 index 00000000000..2a86fd62e32 --- /dev/null +++ b/Explorer/Assets/DCL/PluginSystem/Global/AnalyticsDiskFullPopupPlugin.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9b64da259cad44f1849cdf7f640f3343 diff --git a/Explorer/Assets/DCL/SmartWearables/SmartWearableCache.cs b/Explorer/Assets/DCL/SmartWearables/SmartWearableCache.cs index 6e94d860ee6..914b89540df 100644 --- a/Explorer/Assets/DCL/SmartWearables/SmartWearableCache.cs +++ b/Explorer/Assets/DCL/SmartWearables/SmartWearableCache.cs @@ -1,4 +1,4 @@ -using CommunicationData.URLHelpers; +using CommunicationData.URLHelpers; using Cysharp.Threading.Tasks; using DCL.AvatarRendering.Loading.Components; using DCL.AvatarRendering.Wearables.Components; @@ -32,7 +32,10 @@ public class SmartWearableCache private readonly IWebRequestController webRequestController; private readonly IDecentralandUrlsSource decentralandUrlsSource; + // Readers arrive from load-system flows and main-thread UI concurrently + private readonly object gate = new (); private readonly Dictionary cache = new (); + private readonly Dictionary> inFlight = new (); public SmartWearableCache(IWebRequestController webRequestController, IDecentralandUrlsSource decentralandUrlsSource) { @@ -73,20 +76,20 @@ public static string GetCacheId(IWearable wearable) => /// public async UniTask IsSmartAsync(IWearable wearable, CancellationToken ct) { - CacheItem item = await CacheWearableInternalAsync(wearable, ct); - return !ct.IsCancellationRequested && item.IsSmart; + CacheItem? item = await CacheWearableInternalAsync(wearable, ct); + return item is { IsSmart: true }; } public async UniTask RequiresAuthorizationAsync(IWearable wearable, CancellationToken ct) { - CacheItem item = await CacheWearableInternalAsync(wearable, ct); - return !ct.IsCancellationRequested && item.RequiresAuthorization; + CacheItem? item = await CacheWearableInternalAsync(wearable, ct); + return item is { RequiresAuthorization: true }; } public async UniTask RequiresWeb3APIAsync(IWearable wearable, CancellationToken ct) { - CacheItem item = await CacheWearableInternalAsync(wearable, ct); - return !ct.IsCancellationRequested && item.RequiresWeb3API; + CacheItem? item = await CacheWearableInternalAsync(wearable, ct); + return item is { RequiresWeb3API: true }; } /// @@ -98,30 +101,97 @@ public async UniTask CacheWearableAsync(IWearable wearable, CancellationToken ct await CacheWearableInternalAsync(wearable, ct); } - public bool IsCached(IWearable wearable) => - cache.ContainsKey(GetCacheId(wearable)); + public bool IsCached(IWearable wearable) + { + lock (gate) { return cache.ContainsKey(GetCacheId(wearable)); } + } - public async UniTask<(ISceneContent, SceneMetadata)> GetCachedSceneInfoAsync(IWearable wearable, CancellationToken ct) + public async UniTask<(ISceneContent?, SceneMetadata?)> GetCachedSceneInfoAsync(IWearable wearable, CancellationToken ct) { - CacheItem item = await CacheWearableInternalAsync(wearable, ct); - return ct.IsCancellationRequested ? (null, null) : (item.SceneContent, item.SceneMetadata); + CacheItem? item = await CacheWearableInternalAsync(wearable, ct); + return item == null ? (null, null) : (item.SceneContent, item.SceneMetadata); } public void Clear() { - cache.Clear(); + lock (gate) + { + cache.Clear(); + + // Fetches still running are no longer registered, so they discard their result instead of refilling the cleared cache + inFlight.Clear(); + } + AuthorizedSmartWearables.Clear(); RunningSmartWearables.Clear(); KilledPortableExperiences.Clear(); } - private async UniTask CacheWearableInternalAsync(IWearable wearable, CancellationToken ct) + /// + /// Returns null only when was cancelled. Concurrent callers for the same wearable share one fetch, + /// and an entry becomes visible only once fully built. A failed fetch throws to every awaiting caller. + /// + private async UniTask CacheWearableInternalAsync(IWearable wearable, CancellationToken ct) { string id = GetCacheId(wearable); - if (cache.TryGetValue(id, out CacheItem item)) return item; + UniTaskCompletionSource completion; + var startFetch = false; - item = new CacheItem(); - cache.Add(id, item); + lock (gate) + { + if (cache.TryGetValue(id, out CacheItem item)) return item; + + if (!inFlight.TryGetValue(id, out completion)) + { + completion = new UniTaskCompletionSource(); + inFlight[id] = completion; + startFetch = true; + } + } + + if (startFetch) + FetchIntoAsync(id, wearable, completion).Forget(); + + (bool cancelled, CacheItem result) = await completion.Task.AttachExternalCancellation(ct).SuppressCancellationThrow(); + return cancelled ? null : result; + } + + private async UniTaskVoid FetchIntoAsync(string id, IWearable wearable, UniTaskCompletionSource completion) + { + try + { + CacheItem item = await BuildCacheItemAsync(id, wearable); + + lock (gate) + { + if (RemoveInFlight(id, completion)) + cache[id] = item; + } + + // Completed outside the lock so awaiters' continuations never run while it is held + completion.TrySetResult(item); + } + catch (Exception e) + { + lock (gate) { RemoveInFlight(id, completion); } + + completion.TrySetException(e); + } + } + + // A fetch that outlived Clear() is no longer the registered one and must not repopulate the cache + private bool RemoveInFlight(string id, UniTaskCompletionSource completion) + { + if (!inFlight.TryGetValue(id, out UniTaskCompletionSource current) || current != completion) + return false; + + inFlight.Remove(id); + return true; + } + + private async UniTask BuildCacheItemAsync(string id, IWearable wearable) + { + var item = new CacheItem(); // Null DTO wearable, just consider it non-smart if (wearable.DTO == null) return item; @@ -130,24 +200,28 @@ private async UniTask CacheWearableInternalAsync(IWearable wearable, if (!item.IsSmart) return item; string contentUrl = GetContentUrl(wearable); - item.SceneContent = SmartWearableSceneContent.Create(URLDomain.FromString(contentUrl), wearable, BodyShape.MALE); + SmartWearableSceneContent sceneContent = SmartWearableSceneContent.Create(URLDomain.FromString(contentUrl), wearable, BodyShape.MALE); + item.SceneContent = sceneContent; - if (!item.SceneContent.TryGetContentUrl("scene.json", out URLAddress url)) + if (!sceneContent.TryGetContentUrl("scene.json", out URLAddress url)) { - ReportHub.LogError(ReportCategory.WEARABLE, "Could not find 'scene.json'"); + // Deterministic for this wearable: cached as smart-without-metadata so it is reported once, not on every retry + ReportHub.LogError(ReportCategory.WEARABLE, $"Could not find 'scene.json' for smart wearable '{id}'"); return item; } var args = new CommonLoadingArguments(URLAddress.FromString(url)); - item.SceneMetadata = await webRequestController.GetAsync(args, ct, ReportCategory.WEARABLE) - .CreateFromJson(WRJsonParser.Newtonsoft); - if (ct.IsCancellationRequested) return null; - item.IsSmart &= int.TryParse(item.SceneMetadata.runtimeVersion, out int version) && version >= MIN_SDK_VERSION; + // Owned by the cache rather than by the first caller: a caller cancelling must not leave a half-built entry behind + SceneMetadata sceneMetadata = await webRequestController.GetAsync(args, CancellationToken.None, ReportCategory.WEARABLE) + .CreateFromJson(WRJsonParser.Newtonsoft); + + item.SceneMetadata = sceneMetadata; + item.IsSmart &= int.TryParse(sceneMetadata.runtimeVersion, out int version) && version >= MIN_SDK_VERSION; if (item.IsSmart) { - List permissions = item.SceneMetadata.requiredPermissions; + List permissions = sceneMetadata.requiredPermissions; item.RequiresWeb3API = permissions.Contains(ScenePermissionNames.USE_WEB3_API); item.RequiresAuthorization = item.RequiresWeb3API || @@ -161,6 +235,8 @@ private async UniTask CacheWearableInternalAsync(IWearable wearable, private bool IsSmart(IWearable wearable) { + if (wearable.DTO == null) return false; + foreach (var content in wearable.DTO.content) { if (content.file.EndsWith("scene.json", StringComparison.OrdinalIgnoreCase)) @@ -171,7 +247,7 @@ private bool IsSmart(IWearable wearable) private string GetContentUrl(IWearable smartWearable) { - string? dtoContentUrl = smartWearable.DTO.ContentDownloadUrl; + string? dtoContentUrl = smartWearable.DTO?.ContentDownloadUrl; return string.IsNullOrEmpty(dtoContentUrl) ? $"{decentralandUrlsSource.Url(DecentralandUrl.PeerContent)}/" : dtoContentUrl; } @@ -179,9 +255,9 @@ private class CacheItem { public bool IsSmart; - public ISceneContent SceneContent; + public ISceneContent? SceneContent; - public SceneMetadata SceneMetadata; + public SceneMetadata? SceneMetadata; public bool RequiresAuthorization; diff --git a/Explorer/Assets/DCL/Web3/Authenticators/Implementations/Dapp/DappWeb3EthereumApi.cs b/Explorer/Assets/DCL/Web3/Authenticators/Implementations/Dapp/DappWeb3EthereumApi.cs index e1f1730645e..18874d38efe 100644 --- a/Explorer/Assets/DCL/Web3/Authenticators/Implementations/Dapp/DappWeb3EthereumApi.cs +++ b/Explorer/Assets/DCL/Web3/Authenticators/Implementations/Dapp/DappWeb3EthereumApi.cs @@ -83,7 +83,7 @@ public UniTask SendAsync(EthApiRequest request, CancellationToke public async UniTask SendAsync(EthApiRequest request, Web3RequestSource source, CancellationToken ct) { if (!whitelistMethods.Contains(request.method)) - throw new Web3Exception($"The method is not allowed: {request.method}"); + throw new Web3MethodNotAllowedException($"The method is not allowed: {request.method}"); if (string.Equals(request.method, "eth_accounts") || string.Equals(request.method, "eth_requestAccounts")) diff --git a/Explorer/Assets/DCL/Web3/Authenticators/Implementations/ThirdWeb/ThirdWebEthereumApi.cs b/Explorer/Assets/DCL/Web3/Authenticators/Implementations/ThirdWeb/ThirdWebEthereumApi.cs index 346eeba6541..dade450603d 100644 --- a/Explorer/Assets/DCL/Web3/Authenticators/Implementations/ThirdWeb/ThirdWebEthereumApi.cs +++ b/Explorer/Assets/DCL/Web3/Authenticators/Implementations/ThirdWeb/ThirdWebEthereumApi.cs @@ -50,12 +50,12 @@ public ThirdWebEthereumApi( metaTxService = new ThirdWebMetaTxService(client, URLDomain.FromString(decentralandUrlsSource.Url(DecentralandUrl.MetaTransactionServer)), (request, targetChainId) => SendRpcRequestAsync(request, targetChainId, CancellationToken.None)); } - private string GetRpcUrl(int chainId) + private string GetRpcUrl(int targetChainId) { - if (rpcOverrides.TryGetValue(chainId, out string? rpcUrl)) + if (rpcOverrides.TryGetValue(targetChainId, out string? rpcUrl)) return rpcUrl; - throw new Web3Exception($"No RPC endpoint configured for chain {chainId}. Add it to ChainRpcOverrides in ThirdWebAuthenticator."); + throw new Web3Exception($"No RPC endpoint configured for chain {targetChainId}. Add it to ChainRpcOverrides in ThirdWebAuthenticator."); } public async UniTask SendAsync(IThirdwebWallet? wallet, EthApiRequest request, Web3RequestSource source, CancellationToken ct) @@ -77,19 +77,14 @@ public async UniTask SendAsync(IThirdwebWallet? wallet, EthApiRe if (!whitelistMethods.Contains(request.method)) { - ReportHub.LogError(ReportCategory.AUTHENTICATION, $"ThirdWeb web3 operation: Method not allowed : {request.method}"); - throw new Web3Exception($"The method is not allowed: {request.method}"); + // Scene input rejected by the allow-list, not a fault: warn instead of error + ReportHub.LogWarning(ReportCategory.AUTHENTICATION, $"ThirdWeb web3 operation: Method not allowed : {request.method}"); + throw new Web3MethodNotAllowedException($"The method is not allowed: {request.method}"); } if (IsReadOnly(request)) return await SendWithoutConfirmationAsync(wallet, request, ct); - if (wallet == null) - { - ReportHub.LogError(ReportCategory.AUTHENTICATION, $"ThirdWeb web3 operation: Method not allowed : {request.method}"); - throw new Web3Exception("No active wallet connected"); - } - return await SendWithConfirmationAsync(wallet, request, source, ct); } finally @@ -127,11 +122,11 @@ private async UniTask SendWithoutConfirmationAsync(IThirdwebWall if (string.Equals(request.method, "eth_getBalance") && targetChainId == (int)chainId) { var address = request.@params[0].ToString(); - string walletAddress = await wallet!.GetAddress(); + string walletAddress = await wallet.GetAddress(); if (string.Equals(address, walletAddress, StringComparison.OrdinalIgnoreCase)) { - BigInteger balance = await wallet!.GetBalance(chainId); + BigInteger balance = await wallet.GetBalance(chainId); return new EthApiResponse { @@ -221,7 +216,7 @@ private async UniTask SendWithConfirmationAsync(IThirdwebWallet { // personal_sign params: [message, address] var message = request.@params[0].ToString(); - string signature = await wallet!.PersonalSign(message); + string signature = await wallet.PersonalSign(message); return new EthApiResponse { @@ -235,7 +230,7 @@ private async UniTask SendWithConfirmationAsync(IThirdwebWallet { // eth_signTypedData_v4 params: [address, typedData] var typedDataJson = request.@params[1].ToString(); - string signature = await wallet!.SignTypedDataV4(typedDataJson); + string signature = await wallet.SignTypedDataV4(typedDataJson); return new EthApiResponse { @@ -269,11 +264,11 @@ private async UniTask CreateConfirmationRequestA }; // eth_signTypedData_v4 params: [address, typedData] - if (string.Equals(request.method, "eth_signTypedData_v4") && request.@params?.Length > 1) - confirmationRequest.TypedData = request.@params[1]?.ToString(); + if (string.Equals(request.method, "eth_signTypedData_v4") && request.@params.Length > 1) + confirmationRequest.TypedData = request.@params[1].ToString(); // Extract additional details for eth_sendTransaction - if (string.Equals(request.method, "eth_sendTransaction") && request.@params?.Length > 0) + if (string.Equals(request.method, "eth_sendTransaction") && request.@params.Length > 0) { (string? to, string? value, string? data) = Web3Utils.ParseSendTxRequestParams(request); @@ -283,7 +278,7 @@ private async UniTask CreateConfirmationRequestA try { - BigInteger balanceWei = await wallet!.GetBalance(chainId); + BigInteger balanceWei = await wallet.GetBalance(chainId); confirmationRequest.BalanceEth = balanceWei.ToString().ToEth(decimalsToDisplay: 6, addCommas: false); } catch (Exception e) @@ -295,7 +290,7 @@ private async UniTask CreateConfirmationRequestA try { // Re-parse to build txObject for estimateGas - string from = await wallet!.GetAddress(); + string from = await wallet.GetAddress(); var txObject = new { from, to, value, data }; var estimateGasRequest = new EthApiRequest @@ -360,7 +355,7 @@ private async UniTask HandleSendTransactionAsync(IThirdwebWallet // For simple ETH transfers (no data), use Transfer method if (string.IsNullOrEmpty(data) || data == "0x") { - ThirdwebTransactionReceipt? txReceipt = await wallet!.Transfer( + ThirdwebTransactionReceipt? txReceipt = await wallet.Transfer( chainId, to, weiValue diff --git a/Explorer/Assets/DCL/Web3/Authenticators/Implementations/ThirdWeb/Web3Utils.cs b/Explorer/Assets/DCL/Web3/Authenticators/Implementations/ThirdWeb/Web3Utils.cs index 0138b989b27..2160d293663 100644 --- a/Explorer/Assets/DCL/Web3/Authenticators/Implementations/ThirdWeb/Web3Utils.cs +++ b/Explorer/Assets/DCL/Web3/Authenticators/Implementations/ThirdWeb/Web3Utils.cs @@ -11,12 +11,12 @@ public static (string? to, string? value, string? data) ParseSendTxRequestParams { Dictionary? txParams = JsonConvert.DeserializeObject>(request.@params[0].ToString()); string? to = txParams?.TryGetValue("to", out object? toValue) == true ? toValue?.ToString() : null; - string? value = txParams?.TryGetValue("value", out object? valueValue) == true ? valueValue?.ToString() ?? "0x0" : "0x0"; - string? data = txParams?.TryGetValue("data", out object? dataValue) == true ? dataValue?.ToString() ?? "0x" : "0x"; + string value = txParams?.TryGetValue("value", out object? valueValue) == true ? valueValue?.ToString() ?? "0x0" : "0x0"; + string data = txParams?.TryGetValue("data", out object? dataValue) == true ? dataValue?.ToString() ?? "0x" : "0x"; return (to, value, data); } - public static BigInteger ParseHexToBigInteger(string hexValue) + public static BigInteger ParseHexToBigInteger(string? hexValue) { if (string.IsNullOrEmpty(hexValue) || hexValue == "0x" || hexValue == "0x0") return BigInteger.Zero; diff --git a/Explorer/Assets/DCL/Web3/RestrictedEthereumApi.cs b/Explorer/Assets/DCL/Web3/RestrictedEthereumApi.cs index b72c20b7c8b..71c8717108a 100644 --- a/Explorer/Assets/DCL/Web3/RestrictedEthereumApi.cs +++ b/Explorer/Assets/DCL/Web3/RestrictedEthereumApi.cs @@ -21,7 +21,7 @@ public UniTask SendAsync(EthApiRequest request, CancellationToke public UniTask SendAsync(EthApiRequest request, Web3RequestSource source, CancellationToken ct) { if (!jsApiPermissionsProvider.CanInvokeWeb3API()) - throw new Web3Exception("The Web3 API is not allowed"); + throw new Web3MethodNotAllowedException("The Web3 API is not allowed"); return impl.SendAsync(request, source, ct); } diff --git a/Explorer/Assets/DCL/Web3/Web3MethodNotAllowedException.cs b/Explorer/Assets/DCL/Web3/Web3MethodNotAllowedException.cs new file mode 100644 index 00000000000..1f005082b4a --- /dev/null +++ b/Explorer/Assets/DCL/Web3/Web3MethodNotAllowedException.cs @@ -0,0 +1,9 @@ +namespace DCL.Web3 +{ + /// Separates allow-list rejections from genuine engine or provider faults, which must be reported differently. + public class Web3MethodNotAllowedException : Web3Exception + { + public Web3MethodNotAllowedException(string message) + : base(message) { } + } +} diff --git a/Explorer/Assets/DCL/Web3/Web3MethodNotAllowedException.cs.meta b/Explorer/Assets/DCL/Web3/Web3MethodNotAllowedException.cs.meta new file mode 100644 index 00000000000..70c17085e4f --- /dev/null +++ b/Explorer/Assets/DCL/Web3/Web3MethodNotAllowedException.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cfed614416ee021b4fba956c94225df3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Explorer/Assets/Plugins/RustSegment/.native/Cargo.toml b/Explorer/Assets/Plugins/RustSegment/.native/Cargo.toml index d5fd1038d67..bdbd60d0d10 100644 --- a/Explorer/Assets/Plugins/RustSegment/.native/Cargo.toml +++ b/Explorer/Assets/Plugins/RustSegment/.native/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] lazy_static = "1.5.0" -segment = { git = "https://github.com/decentraland/segment", rev = "2d46f10f9a5da2feac6554daedfc17d1ba49952e", default-features = false, features = ["rustls-tls"] } +segment = { git = "https://github.com/decentraland/segment", rev = "51d89ce2556a36c57122b35cc9391e9727080435", default-features = false, features = ["rustls-tls"] } serde = "1.0.210" serde_json = "1.0.128" tokio = { version = "1.40.0", features = ["full", "parking_lot"] } @@ -16,6 +16,18 @@ anyhow = "1.0.100" futures = "0.3.32" crossbeam-queue = "0.3.12" +# rusqlite is declared here (mirroring segment's per-target declarations, so cargo +# unifies both onto one crate instance) to let the bridge inspect the sqlite error +# code inside segment's EnqueError and classify disk-full. +[target.'cfg(target_os = "macos")'.dependencies] +rusqlite = { version = "0.37" } + +[target.'cfg(target_os = "windows")'.dependencies] +rusqlite = { version = "0.37", features = ["bundled"] } + +[target.'cfg(target_os = "linux")'.dependencies] +rusqlite = { version = "0.37", features = ["bundled"] } + [lib] crate-type = ["lib", "cdylib"] diff --git a/Explorer/Assets/Plugins/RustSegment/.native/src/lib.rs b/Explorer/Assets/Plugins/RustSegment/.native/src/lib.rs index eca31270e70..ebbada7671f 100644 --- a/Explorer/Assets/Plugins/RustSegment/.native/src/lib.rs +++ b/Explorer/Assets/Plugins/RustSegment/.native/src/lib.rs @@ -10,11 +10,13 @@ pub type OperationHandleId = u64; pub const INVALID_OPERATION_HANDLE_ID: OperationHandleId = 0; #[repr(u8)] -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub enum Response { Success = 0, // Errors are propagated vie the error callback Error = 1, + // SQLITE_FULL from the persistent queue + ErrorDiskFull = 2, } /// # SAFTEY: The "C" callback must be threadsafe and not block diff --git a/Explorer/Assets/Plugins/RustSegment/.native/src/server.rs b/Explorer/Assets/Plugins/RustSegment/.native/src/server.rs index 68889b949fa..2b9c482002b 100644 --- a/Explorer/Assets/Plugins/RustSegment/.native/src/server.rs +++ b/Explorer/Assets/Plugins/RustSegment/.native/src/server.rs @@ -14,7 +14,7 @@ use tokio::sync::Mutex; use segment::{ message::{BatchMessage, User}, queue::{ - event_queue::{CombinedAnalyticsEventQueue, CombinedAnalyticsEventQueueNewResult}, + event_queue::{CombinedAnalyticsEventQueue, CombinedAnalyticsEventQueueNewResult, EnqueError}, event_send_daemon::AnalyticsEventSendDaemon, }, Client, HttpClient, @@ -360,10 +360,14 @@ impl SegmentServer { pub async fn enqueue(&self, id: OperationHandleId, msg: impl Into) { if let Err(e) = self.enqueue_internal(msg).await { + // enqueue_internal wraps EnqueError in anyhow, downcast to tell a full disk from other failures + let code = e + .downcast_ref::() + .map_or(Response::Error, response_code_for_enque_error); self.context .lock() .await - .report_error(Some(id), format!("Cannot enqueue: {e}")); + .report_error_with_code(Some(id), format!("Cannot enqueue: {e}"), code); } else { self.context.lock().await.report_success(id); } @@ -391,7 +395,8 @@ impl SegmentServer { let mut context = instance.context.lock().await; if let Err(e) = context.batcher.flush().await { - context.report_error(Some(id), format!("Cannot flush: {e}")); + let code = response_code_for_enque_error(&e); + context.report_error_with_code(Some(id), format!("Cannot flush: {e}"), code); } else { context.report_success(id); } @@ -418,22 +423,81 @@ impl SegmentServer { } } +fn response_code_for_enque_error(error: &EnqueError) -> Response { + match error { + EnqueError::Sqlite(e) + if matches!(e.sqlite_error_code(), Some(rusqlite::ErrorCode::DiskFull)) => + { + Response::ErrorDiskFull + } + _ => Response::Error, + } +} + impl AppContext { pub fn report_success(&self, id: OperationHandleId) { self.callback_fn.as_ref()(id, Response::Success); } pub fn report_error(&self, id: Option, message: String) { + self.report_error_with_code(id, message, Response::Error); + } + + pub fn report_error_with_code( + &self, + id: Option, + message: String, + code: Response, + ) { let message = match id { Some(id) => { format!("Operation {id} failed: {message}") } None => message, }; - self.error_fn.as_ref()(message.as_str()); + + // The error-string channel is for genuine faults; a full disk is an environment condition already carried by the code + if !matches!(code, Response::ErrorDiskFull) { + self.error_fn.as_ref()(message.as_str()); + } if let Some(id) = id { - self.callback_fn.as_ref()(id, Response::Error); + self.callback_fn.as_ref()(id, code); }; } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sqlite_enque_error(code: std::os::raw::c_int) -> EnqueError { + EnqueError::Sqlite(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(code), + None, + )) + } + + #[test] + fn sqlite_full_maps_to_disk_full_response() { + let error = sqlite_enque_error(rusqlite::ffi::SQLITE_FULL); + assert!(matches!( + response_code_for_enque_error(&error), + Response::ErrorDiskFull + )); + } + + #[test] + fn other_errors_map_to_generic_response() { + let error = sqlite_enque_error(rusqlite::ffi::SQLITE_BUSY); + assert!(matches!( + response_code_for_enque_error(&error), + Response::Error + )); + + assert!(matches!( + response_code_for_enque_error(&EnqueError::LimitReached), + Response::Error + )); + } +} diff --git a/Explorer/Assets/Plugins/RustSegment/README.md b/Explorer/Assets/Plugins/RustSegment/README.md index 40a40d8b855..6bf82961561 100644 --- a/Explorer/Assets/Plugins/RustSegment/README.md +++ b/Explorer/Assets/Plugins/RustSegment/README.md @@ -78,6 +78,18 @@ Notes: Operations are asynchronous. Completion and errors are reported via the provided callbacks. +### Response codes + +Each operation completes through `FfiCallbackFn` with one of: + +| Code | Name | Meaning | +|---|---|---| +| 0 | `Success` | Operation completed. | +| 1 | `Error` | Generic failure; details arrive via the error callback. | +| 2 | `ErrorDiskFull` | The persistent queue cannot write because the disk is full (SQLITE_FULL). | + +`Libraries/Linux/segment-server.so` predates `ErrorDiskFull` and has not been rebuilt (Linux is not a release target), so on Linux a full disk still completes with `Error`. + --- ## Shutdown / Disposal Contract diff --git a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/Libraries/Mac/segment-server.dylib b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/Libraries/Mac/segment-server.dylib index 30adc26fb9a..19e1d79d06e 100755 --- a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/Libraries/Mac/segment-server.dylib +++ b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/Libraries/Mac/segment-server.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d14f5fbfb63b35d841346d36dca0dae545992c6aec887adcc484ff6bf5a4c7fc -size 6452448 +oid sha256:9debf46ac3af4a86b03d119a79a98f240cdeba6e628c2e1bfd64d932c956bdf4 +size 6270256 diff --git a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/Libraries/Windows/segment-server.dll b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/Libraries/Windows/segment-server.dll index 29c6f442d93..e12c29f3c9a 100644 --- a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/Libraries/Windows/segment-server.dll +++ b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/Libraries/Windows/segment-server.dll @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd98676dd01f93691a8f1fa115b999898dc47e0de1e711eb5166092c5af1460c -size 4560896 +oid sha256:a95ec0c19b3151476dd728de5a81104496744cce834e461a9f476b428ed08f27 +size 4324352 diff --git a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/NativeMethods.cs b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/NativeMethods.cs index 08dad46aafe..3d2c83d4c42 100644 --- a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/NativeMethods.cs +++ b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/NativeMethods.cs @@ -13,6 +13,7 @@ internal enum Response : byte { Success = 0, Error = 1, + ErrorDiskFull = 2, } [UnmanagedFunctionPointer(CALLING_CONVENTION)] diff --git a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs index 93a73fe2ab2..481446bd4f8 100644 --- a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs +++ b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs @@ -12,6 +12,7 @@ using UnityEngine.Device; using UnityEngine.Pool; using Cysharp.Threading.Tasks; +using Utility; using Utility.Multithreading; namespace Plugins.RustSegment.SegmentServerWrap @@ -52,8 +53,11 @@ private enum Operation // temportal sentry budget fix. TODO remove once the core issue solved private static bool ONCE_PATTERN_ALREADY_CAUGHT = false; - public RustSegmentAnalyticsService(string writerKey, string? anonId) + private readonly IEventBus? eventBus; + + public RustSegmentAnalyticsService(string writerKey, string? anonId, IEventBus? eventBus = null) { + this.eventBus = eventBus; using Mutex.Guard instanceGuard = CURRENT.Lock(); // IGNORE_LINE_WEBGL_THREAD_SAFETY_FLAG if (string.IsNullOrWhiteSpace(writerKey)) @@ -266,7 +270,15 @@ private static void Callback(ulong operationId, NativeMethods.Response response) ReportHub.Log(ReportCategory.ANALYTICS, $"Segment Operation {operationId} {type} finished with: {response}"); - if (response is not NativeMethods.Response.Success) + if (response is NativeMethods.Response.ErrorDiskFull) + { + // Environment condition, not an app bug: warn instead of a Sentry exception + ReportHub.LogWarning(ReportCategory.ANALYTICS, $"Segment operation {operationId} {type} failed: disk is full"); + + // Published on every occurrence since subscribers may not exist yet during boot + instanceGuard.Value.eventBus?.Publish(new AnalyticsDiskFullDetected()); + } + else if (response is not NativeMethods.Response.Success) ReportHub.LogException(new Exception($"Segment operation {operationId} {type} failed with: {response}"), ReportCategory.ANALYTICS); instanceGuard.Value.CleanMemory(operationId);