Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a50c59c
fix: drop oversized comms scene messages instead of throwing (#9634)
lorenzo-ranciaffi Aug 21, 2026
c7779c7
fix: gate scene JS exceptions out of production Sentry during local d…
lorenzo-ranciaffi Aug 21, 2026
6845c2a
fix: guard CTS double-dispose in LiveKit movement bus and scene runti…
lorenzo-ranciaffi Aug 21, 2026
878233b
fix: emote play-timeout watchdog never fires, stranding emote intents…
lorenzo-ranciaffi Aug 21, 2026
f9bf087
fix: report scene UI panel's real pixels-per-point as DevicePixelRati…
lorenzo-ranciaffi Aug 21, 2026
845a999
fix: unblock camera/avatar input after paste or Alt-Tab focus loss (#…
lorenzo-ranciaffi Aug 21, 2026
cc2fa3b
fix: retry avatar thumbnail loads after a failed attempt instead of r…
lorenzo-ranciaffi Aug 21, 2026
2605aff
fix: evict corrupt Unity AB cache entries and retry the download once…
lorenzo-ranciaffi Aug 21, 2026
dde3f4e
fix: cancel stalled login profile fetch instead of abandoning it (#9792)
lorenzo-ranciaffi Aug 21, 2026
a483bb5
fix: send mention wallet strings, not UserId objects, in chat analyti…
lorenzo-ranciaffi Aug 21, 2026
d85e086
perf: eliminate per-message topic string alloc in comms receive path …
lorenzo-ranciaffi Aug 21, 2026
9ca73dd
fix: release CRDT sync buffer rent slot on failed batches (#9795)
lorenzo-ranciaffi Aug 21, 2026
0603cfe
fix: make debug console log ingestion thread-safe via pending queue (…
lorenzo-ranciaffi Aug 21, 2026
9ce7f7f
perf: pool the off-main-thread EventBus publish continuation (#9798)
lorenzo-ranciaffi Aug 21, 2026
39e9f68
fix: keep checked-out GLTF clones alive when AssetPreLoadCache clears…
lorenzo-ranciaffi Aug 21, 2026
a15111e
fix: fall back to unconverted textures when the ktx native decoder ca…
lorenzo-ranciaffi Aug 21, 2026
5148453
fix: dispose never-shown AuthenticationScreenController without NRE (…
lorenzo-ranciaffi Aug 21, 2026
26897c9
fix: resize map render texture when screen resolution changes (#9803)
lorenzo-ranciaffi Aug 21, 2026
819c638
perf: remove per-poll List allocation in notifications polling (#9804)
lorenzo-ranciaffi Aug 21, 2026
f67f584
fix: resolve private conversation user state without friends service …
lorenzo-ranciaffi Aug 21, 2026
73b8379
fix: left-pad private key bytes to 32 before rust sign-server init (#…
lorenzo-ranciaffi Aug 21, 2026
c37fdb3
fix: discard duplicate scene facade for already-cached parcels and se…
lorenzo-ranciaffi Aug 21, 2026
1eef5e9
fix: guard thumbnail disposal on Succeeded so failed results don't NR…
lorenzo-ranciaffi Aug 21, 2026
c751557
fix: abandon the parked connect await when an unestablished close abo…
lorenzo-ranciaffi Aug 21, 2026
b6ad726
chore: trim comments and address review feedback
alejandro-jimenez-dcl Aug 21, 2026
48c6260
fix tests
lorenzo-ranciaffi Aug 21, 2026
f5525ac
fix tests
lorenzo-ranciaffi Aug 21, 2026
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
Expand Up @@ -92,6 +92,7 @@ public enum AuthStatus
internal void RaiseProfileFinalized() =>
ProfileFinalized?.Invoke();

// Null until OnViewInstantiated: the view is created lazily on first Show and may never be instantiated.
private MVCStateMachine<AuthStateBase>? fsm;
private AuthenticationScreenAudio? audio;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public override void Exit()
ProfileNotFoundException ex => new SpanErrorInfo($"Profile not found during {nameof(ProfileFetchingAuthState)}", ex),
NotAllowedUserException ex => new SpanErrorInfo(ex.Message, ex),
TimeoutException ex => new SpanErrorInfo($"Profile fetch timed out during {nameof(ProfileFetchingAuthState)}", ex),
Exception ex => new SpanErrorInfo($"Unexpected error during {nameof(ProfileFetchingAuthState)}", ex),
{ } ex => new SpanErrorInfo($"Unexpected error during {nameof(ProfileFetchingAuthState)}", ex),
};

if (profileFetchException is not OperationCanceledException and not ProfileNotFoundException and not NotAllowedUserException)
Expand Down Expand Up @@ -110,9 +110,7 @@ private async UniTaskVoid FetchProfileFlowAsync(string email, IWeb3Identity iden
});

// Timeout surfaces catalyst stalls as CONNECTION_ERROR instead of a frozen spinner.
Profile? profile = await selfProfile.ProfileAsync(ct).Timeout(PROFILE_FETCH_TIMEOUT);

if (profile != null)
if (await FetchProfileWithTimeoutAsync(selfProfile, PROFILE_FETCH_TIMEOUT, ct) is { } profile)
{
// When the profile was already in cache, for example your previous account after logout, we need to ensure that all systems related to the profile will update
profile.IsDirty = true;
Expand Down Expand Up @@ -156,6 +154,28 @@ private async UniTaskVoid FetchProfileFlowAsync(string email, IWeb3Identity iden
}
}

/// <summary>
/// Runs the fetch under a linked token so a timeout cancels the underlying request. Timeout throws
/// <see cref="TimeoutException" />; cancellation of <paramref name="ct" /> throws <see cref="OperationCanceledException" />.
/// </summary>
internal static async UniTask<Profile?> FetchProfileWithTimeoutAsync(ISelfProfile selfProfile, TimeSpan timeout, CancellationToken ct)
{
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
using IDisposable timeoutTimer = timeoutCts.CancelAfterSlim(timeout);

if (await selfProfile.ProfileAsync(timeoutCts.Token) is { } profile)
return profile;

// The repository suppresses cancellation into a null profile; rethrow external cancellation as OCE
// so it is not misread as "no deployed profile"
ct.ThrowIfCancellationRequested();

if (timeoutCts.IsCancellationRequested)
throw new TimeoutException($"Profile fetch timed out after {timeout.TotalSeconds:F0}s");

return null; // genuine "no deployed profile"
}

private Profile CreateRandomProfile(string identityAddress)
{
var profile = Profile.NewRandomProfile(identityAddress);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using NUnit.Framework;

namespace DCL.AuthenticationScreenFlow.Tests
{
[TestFixture]
public class AuthenticationScreenControllerShould
{
[Test]
public void NotThrowOnDisposeWhenViewWasNeverShown()
{
// Never-shown lifecycle (--skip-auth-screen with a cached identity): OnViewInstantiated never runs,
// so lazily-created members stay null; the constructor only stores dependencies, so null! args are safe.
var controller = new AuthenticationScreenController(
() => null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
string.Empty,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!);

Assert.DoesNotThrow(controller.Dispose);
}
}
}

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,226 @@
using Cysharp.Threading.Tasks;
using DCL.Profiles;
using DCL.Profiles.Self;
using DCL.Utilities;
using DCL.Web3.Identities;
using MVC;
using NSubstitute;
using NUnit.Framework;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.Serialization;
using System.Threading;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.UI;
using static DCL.AuthenticationScreenFlow.AuthenticationScreenController;

namespace DCL.AuthenticationScreenFlow.Tests
{
[TestFixture]
public class ProfileFetchingAuthStateShould
{
// Mirrors ProfileFetchingAuthState.PROFILE_FETCH_TIMEOUT (private)
private const float FETCH_TIMEOUT_SECONDS = 15f;

// Long enough for the fetch timeout to fire and be observed
private const float OBSERVATION_SECONDS = 16.5f;

[UnityTest]
public IEnumerator CancelStalledFetchOnTimeout() =>
UniTask.ToCoroutine(async () =>
{
// No states registered: transitions throw and log via the fire-and-forget flow; those logs are irrelevant here
LogAssert.ignoreFailingMessages = true;

using var cts = new CancellationTokenSource();
var root = new GameObject(nameof(ProfileFetchingAuthStateShould));

try
{
AuthenticationScreenView screenView = root.AddComponent<AuthenticationScreenView>();

var viewGo = new GameObject(nameof(ProfileFetchingAuthView));
viewGo.transform.SetParent(root.transform);
StubProfileFetchingAuthView fetchingView = viewGo.AddComponent<StubProfileFetchingAuthView>();

var buttonGo = new GameObject("CancelButton");
buttonGo.transform.SetParent(viewGo.transform);
Button cancelButton = buttonGo.AddComponent<Button>();

SetBackingField(fetchingView, typeof(ProfileFetchingAuthView), nameof(ProfileFetchingAuthView.CancelButton), cancelButton);
SetBackingField(screenView, typeof(AuthenticationScreenView), nameof(AuthenticationScreenView.ProfileFetchingAuthView), fetchingView);

var machine = new MVCStateMachine<AuthStateBase>();
var selfProfile = new StalledSelfProfile();

// The controller is only captured for the Cancel button listener, which is never invoked here
var controller = (AuthenticationScreenController)FormatterServices.GetUninitializedObject(typeof(AuthenticationScreenController));

var state = new ProfileFetchingAuthState(
machine,
screenView,
controller,
new ReactiveProperty<AuthStatus>(AuthStatus.None),
selfProfile,
Substitute.For<IWeb3IdentityCache>());

state.Enter(new ProfileFetchingPayload(Substitute.For<IWeb3Identity>(), true, cts.Token));

float deadline = UnityEngine.Time.realtimeSinceStartup + OBSERVATION_SECONDS;

while (UnityEngine.Time.realtimeSinceStartup < deadline)
await UniTask.Yield();

Assert.That(selfProfile.CapturedTokens.Count, Is.EqualTo(1), "the profile fetch must run exactly once (no retries)");

Assert.That(selfProfile.CapturedTokens[0].IsCancellationRequested, Is.True,
$"the fetch's token must be cancelled once the {FETCH_TIMEOUT_SECONDS}s timeout elapses; " +
"an uncancelled token means the request was abandoned and keeps poisoning the repository's ongoing batch");
}
finally
{
// Unblock the pending attempt so the detached flow finishes inside this test's ignore-failing-messages window
cts.Cancel();

for (var i = 0; i < 32; i++)
await UniTask.Yield();

UnityEngine.Object.DestroyImmediate(root);
}
});

[UnityTest]
public IEnumerator SurfaceExternalCancellationInsteadOfMissingProfile() =>
UniTask.ToCoroutine(async () =>
{
var selfProfile = new StalledSelfProfile();
using var cts = new CancellationTokenSource();

UniTask<Profile?> fetch = ProfileFetchingAuthState.FetchProfileWithTimeoutAsync(
selfProfile, TimeSpan.FromSeconds(FETCH_TIMEOUT_SECONDS), cts.Token);

float deadline = UnityEngine.Time.realtimeSinceStartup + 5f;

while (selfProfile.CapturedTokens.Count == 0 && UnityEngine.Time.realtimeSinceStartup < deadline)
await UniTask.Yield();

Assert.That(selfProfile.CapturedTokens.Count, Is.EqualTo(1), "the fetch must be in flight before the external cancel");

cts.Cancel();

try
{
Profile? result = await fetch;

Assert.Fail("cancelling the flow token must surface as OperationCanceledException, not be read as " +
$"\"no deployed profile\" (got {(result == null ? "null" : "a profile")}); a null here wipes " +
"a still-valid cached identity on the cached flow");
}
catch (OperationCanceledException) { }
});

[UnityTest]
public IEnumerator ThrowTimeoutWhenFetchStalls() =>
UniTask.ToCoroutine(async () =>
{
var selfProfile = new StalledSelfProfile();

try
{
await ProfileFetchingAuthState.FetchProfileWithTimeoutAsync(
selfProfile, TimeSpan.FromSeconds(0.25), CancellationToken.None);

Assert.Fail("a stalled fetch must surface as TimeoutException");
}
catch (TimeoutException) { }

Assert.That(selfProfile.CapturedTokens.Count, Is.EqualTo(1), "the fetch must run exactly once");

Assert.That(selfProfile.CapturedTokens[0].IsCancellationRequested, Is.True,
"a timed-out fetch must cancel its own request instead of abandoning it");
});

[UnityTest]
public IEnumerator ReturnNullWhenProfileIsNotDeployed() =>
UniTask.ToCoroutine(async () =>
{
var selfProfile = new MissingProfileSelfProfile();

Profile? result = await ProfileFetchingAuthState.FetchProfileWithTimeoutAsync(
selfProfile, TimeSpan.FromSeconds(FETCH_TIMEOUT_SECONDS), CancellationToken.None);

Assert.That(result, Is.Null);
Assert.That(selfProfile.Calls, Is.EqualTo(1), "a genuine \"no deployed profile\" must resolve on the single fetch");
});

private static void SetBackingField(object target, Type declaringType, string propertyName, object value)
{
FieldInfo? field = declaringType.GetField($"<{propertyName}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null, $"auto-property backing field for {declaringType.Name}.{propertyName} not found");
field!.SetValue(target, value);
}

/// <summary>
/// Stalled catalyst request. Mirrors <see cref="SelfProfile.ProfileAsync" />: cancellation is
/// suppressed into a null profile, never surfaced as an exception.
/// </summary>
private class StalledSelfProfile : ISelfProfile
{
public readonly List<CancellationToken> CapturedTokens = new ();

public event Action<Profile>? ProfilePropagated;

public async UniTask<Profile?> ProfileAsync(CancellationToken ct)
{
CapturedTokens.Add(ct);

try { return await UniTask.Never<Profile?>(ct); }
catch (OperationCanceledException) { return null; }
}

public UniTask<Profile?> UpdateProfileAsync(CancellationToken ct, bool updateAvatarInWorld = true) =>
UniTask.FromResult<Profile?>(null);

public UniTask<Profile?> UpdateProfileAsync(Profile profile, CancellationToken ct, bool updateAvatarInWorld = true) =>
UniTask.FromResult<Profile?>(null);

public void Dispose() { }
}

/// <summary>
/// Responsive catalyst with no deployed profile: resolves to null immediately, no cancellation involved.
/// </summary>
private class MissingProfileSelfProfile : ISelfProfile
{
public int Calls { get; private set; }

public event Action<Profile>? ProfilePropagated;

public UniTask<Profile?> ProfileAsync(CancellationToken ct)
{
Calls++;
return UniTask.FromResult<Profile?>(null);
}

public UniTask<Profile?> UpdateProfileAsync(CancellationToken ct, bool updateAvatarInWorld = true) =>
UniTask.FromResult<Profile?>(null);

public UniTask<Profile?> UpdateProfileAsync(Profile profile, CancellationToken ct, bool updateAvatarInWorld = true) =>
UniTask.FromResult<Profile?>(null);

public void Dispose() { }
}
}

public class StubProfileFetchingAuthView : ProfileFetchingAuthView
{
public override UniTask ShowAsync(CancellationToken ct) =>
UniTask.CompletedTask;

public override UniTask HideAsync(CancellationToken ct, bool isInstant = false) =>
UniTask.CompletedTask;
}
}

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

Loading
Loading