Skip to content

Commit 4ffbb62

Browse files
refactor: make ISceneTipsProvider synchronous and release tips unconditionally
Both implementations resolve synchronously (the localization provider serves a cached table loaded in InitializeAsync, and the feature-flag decorator only transforms that result), so the async signature suggested a suspension point that cannot happen. Making Get() sync and fetching in OnBeforeViewShow means tips is populated on every path that reaches OnViewClose, so the release there needs no emptiness guard (review feedback on the previous guard).
1 parent 6dde43d commit 4ffbb62

5 files changed

Lines changed: 43 additions & 73 deletions

File tree

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
1-
using Cysharp.Threading.Tasks;
2-
using System.Threading;
3-
using UnityEngine;
4-
51
namespace DCL.SceneLoadingScreens
62
{
73
public interface ISceneTipsProvider
84
{
9-
// TODO: in the future we may require the parcel coordinate to provide specific scene tips
10-
// UniTask<SceneTips> Get(Vector2Int parcelCoord, CancellationToken ct);
11-
UniTask<SceneTips> GetAsync(CancellationToken ct);
5+
// TODO: in the future we may require the parcel coordinate to provide specific scene tips,
6+
// which would make this async again: UniTask<SceneTips> GetAsync(Vector2Int parcelCoord, CancellationToken ct)
7+
SceneTips Get();
128
}
139
}

Explorer/Assets/DCL/SceneLoadingScreens/SceneLoadingScreenController.cs

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ protected override void OnBeforeViewShow()
104104
BlockUnwantedInputs();
105105
SetLoadProgress(0);
106106
viewInstance!.ClearTips();
107+
108+
// Fetched synchronously before the view shows, so `tips` is populated on every path
109+
// that can reach OnViewClose and the release there needs no emptiness guard.
110+
tips = sceneTipsProvider.Get();
111+
112+
if (tips.Random)
113+
tips.Tips.Shuffle();
107114
}
108115

109116
protected override void OnViewShow()
@@ -137,19 +144,12 @@ protected override void OnViewClose()
137144
audioMixerVolumesController.UnmuteGroup(AudioMixerExposedParam.Chat_Volume);
138145

139146
viewInstance!.ClearTips();
140-
141-
// Tips is null until the first load completes: a close racing that load must not
142-
// release a default instance, and a later close must not release the same tips twice.
143-
if (tips.Tips != null)
144-
{
145-
tips.Release();
146-
tips = default;
147-
}
147+
tips.Release();
148148
}
149149

150150
protected override async UniTask WaitForCloseIntentAsync(CancellationToken ct)
151151
{
152-
await LoadTipsAsync(ct);
152+
await LoadTipsAsync();
153153

154154
ShowTip(currentTip.Value);
155155

@@ -193,13 +193,8 @@ private async UniTask WaitTimeThresholdAsync(float progressProportion, Cancellat
193193
}
194194
}
195195

196-
private async UniTask LoadTipsAsync(CancellationToken ct)
196+
private async UniTask LoadTipsAsync()
197197
{
198-
tips = await sceneTipsProvider.GetAsync(ct);
199-
200-
if (tips.Random)
201-
tips.Tips.Shuffle();
202-
203198
using var scope = ListPool<UniTask<SceneTips.LoadedTip>>.Get(out var tasks);
204199
foreach (SceneTips.Tip tip in tips.Tips) tasks.Add(tip.LoadAsync());
205200
var loaded = await UniTask.WhenAll(tasks!);

Explorer/Assets/DCL/SceneLoadingScreens/Tests/SceneLoadingScreenControllerInputBlockShould.cs

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
using System.Threading;
1616
using System.Threading.Tasks;
1717
using UnityEditor;
18-
using UnityEngine;
1918
using UnityEngine.Audio;
2019
using UnityEngine.TestTools;
2120
using Object = UnityEngine.Object;
@@ -53,11 +52,11 @@ public class SceneLoadingScreenControllerInputBlockShould
5352
// suppression is still active, instead of leaking into an unrelated later test.
5453
private const int FLUSH_FRAME_COUNT = 30;
5554

56-
private World world;
55+
private World? world;
5756
private SingleInstanceEntity inputMapEntity;
58-
private IInputBlock inputBlock;
59-
private SceneLoadingScreenView viewInstance;
60-
private AudioMixerVolumesController audioMixerVolumesController;
57+
private IInputBlock? inputBlock;
58+
private SceneLoadingScreenView? viewInstance;
59+
private AudioMixerVolumesController? audioMixerVolumesController;
6160
private bool originalIgnoreFailingMessages;
6261

6362
[OneTimeSetUp]
@@ -119,7 +118,7 @@ public void TearDown()
119118
if (viewInstance != null)
120119
Object.DestroyImmediate(viewInstance.gameObject);
121120

122-
world.Dispose();
121+
world!.Dispose();
123122

124123
// Reset the static field so later tests in the same run aren't left with a stale in-memory
125124
// prefs instance (mirrors the reset half of the same established pattern).
@@ -134,12 +133,7 @@ public async Task ReleaseInputBlockWhenCloseIntentIsCancelledAsync()
134133
// suppression - the flag must be raised inside the test body itself.
135134
LogAssert.ignoreFailingMessages = true;
136135

137-
ISceneTipsProvider tipsProvider = Substitute.For<ISceneTipsProvider>();
138-
139-
tipsProvider.GetAsync(Arg.Any<CancellationToken>())
140-
.Returns(UniTask.FromResult(new SceneTips(TimeSpan.Zero, false, new List<SceneTips.Tip>())));
141-
142-
SceneLoadingScreenController controller = CreateController(tipsProvider);
136+
SceneLoadingScreenController controller = CreateController(EmptyTipsProvider());
143137

144138
using var cts = new CancellationTokenSource();
145139
cts.Cancel();
@@ -168,50 +162,30 @@ public async Task ReleaseInputBlockWhenCloseIntentIsCancelledAsync()
168162
}
169163

170164
[Test]
171-
public async Task ReleaseInputBlockWhenCloseRacesTheInitialTipsLoadAsync()
165+
public async Task ReleaseInputBlockWhenClosedWhileSceneIsStillLoadingAsync()
172166
{
173167
// The framework resets LogAssert state at test start, wiping any fixture/SetUp-scoped
174168
// suppression - the flag must be raised inside the test body itself.
175169
LogAssert.ignoreFailingMessages = true;
176170

177-
ISceneTipsProvider tipsProvider = Substitute.For<ISceneTipsProvider>();
178-
179-
// The tips load never resolves during this test, so `tips` stays at its default value
180-
// (Tips == null) for the whole run - exactly the close-races-the-initial-load scenario from
181-
// review.md ("earliest-cancel variant... cold addressables make the tips window largest on
182-
// first show") that made the first patch attempt's placement of the release - after
183-
// tips.Release() - still leak, because tips.Release() throws on a default SceneTips before
184-
// reaching it.
185-
tipsProvider.GetAsync(Arg.Any<CancellationToken>()).Returns(UniTask.Never<SceneTips>(CancellationToken.None));
186-
187-
SceneLoadingScreenController controller = CreateController(tipsProvider);
171+
SceneLoadingScreenController controller = CreateController(EmptyTipsProvider());
188172

189-
// Deliberately not awaited: LaunchViewLifeCycleAsync runs synchronously through
190-
// OnBeforeViewShow/OnViewShow and suspends inside LoadTipsAsync (awaiting a promise that
191-
// never completes), so by the time control returns here the block has already been
192-
// acquired and WaitForCloseIntentAsync is still in flight - matching the real
193-
// MVCManager.ShowOverlayAsync race where the teardown's finally can call HideViewAsync while
194-
// the orphaned lifecycle task is still suspended.
195-
UniTask launch = controller.LaunchViewLifeCycleAsync(new CanvasOrdering(CanvasOrdering.SortingLayer.Overlay, 0), CompletedParams(), CancellationToken.None);
173+
// A load report that never completes keeps WaitForCloseIntentAsync suspended waiting on it,
174+
// so the fade-out (the unpatched code's only release) never runs. Deliberately not awaited:
175+
// this matches the real MVCManager.ShowOverlayAsync race where the teardown's finally calls
176+
// HideViewAsync while the orphaned lifecycle task is still suspended (teleport superseded
177+
// mid-load - leak path 2 from report.md).
178+
AsyncLoadProcessReport pendingReport = AsyncLoadProcessReport.Create(CancellationToken.None);
179+
UniTask launch = controller.LaunchViewLifeCycleAsync(new CanvasOrdering(CanvasOrdering.SortingLayer.Overlay, 0), new SceneLoadingScreenController.Params(pendingReport), CancellationToken.None);
196180

197181
Assert.That(ActiveKinds(), Is.EqualTo(ALL_KINDS & ~BLOCKED_BY_LOADING_SCREEN),
198182
"input should be blocked right after showing the loading screen");
199183

200-
try
201-
{
202-
await ((IController)controller).HideViewAsync(CancellationToken.None);
203-
}
204-
catch (NullReferenceException)
205-
{
206-
// Pre-existing, separate defect (review.md finding 1): unpatched OnViewClose() calls
207-
// tips.Release() on a still-default `tips` and throws. That defect is not what is under
208-
// test here - what matters is whether the input block was released before that
209-
// statement could run at all, which is asserted below regardless of this exception.
210-
}
184+
await ((IController)controller).HideViewAsync(CancellationToken.None);
211185

212186
Assert.That(ActiveKinds(), Is.EqualTo(ALL_KINDS),
213-
"BLOCK_USER_INPUT must be released even when OnViewClose races the initial tips load - " +
214-
"unpatched, this leaks +1 on the refcount forever (#9502)");
187+
"BLOCK_USER_INPUT must be released even when the loading screen closes while the scene " +
188+
"is still loading - unpatched, this leaks +1 on the refcount forever (#9502)");
215189

216190
launch.Forget();
217191

@@ -220,8 +194,15 @@ public async Task ReleaseInputBlockWhenCloseRacesTheInitialTipsLoadAsync()
220194
await FlushDeferredViewLogsAsync();
221195
}
222196

197+
private static ISceneTipsProvider EmptyTipsProvider()
198+
{
199+
ISceneTipsProvider tipsProvider = Substitute.For<ISceneTipsProvider>();
200+
tipsProvider.Get().Returns(new SceneTips(TimeSpan.Zero, false, new List<SceneTips.Tip>()));
201+
return tipsProvider;
202+
}
203+
223204
private SceneLoadingScreenController CreateController(ISceneTipsProvider tipsProvider) =>
224-
new (() => viewInstance, tipsProvider, TimeSpan.Zero, audioMixerVolumesController, inputBlock);
205+
new (() => viewInstance!, tipsProvider, TimeSpan.Zero, audioMixerVolumesController!, inputBlock!);
225206

226207
private static SceneLoadingScreenController.Params CompletedParams()
227208
{
@@ -231,7 +212,7 @@ private static SceneLoadingScreenController.Params CompletedParams()
231212
}
232213

233214
private InputMapComponent.Kind ActiveKinds() =>
234-
inputMapEntity.GetInputMapComponent(world).Active;
215+
inputMapEntity.GetInputMapComponent(world!).Active;
235216

236217
private static InputMapComponent.Kind AllKinds()
237218
{

Explorer/Assets/DCL/SceneLoadingScreens/TipsFromFeatureFlagDecorator.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
using Cysharp.Threading.Tasks;
21
using DCL.FeatureFlags;
32
using DCL.PerformanceAndDiagnostics.Analytics;
43
using System;
54
using System.Collections.Generic;
65
using System.Linq;
76
using System.Runtime.Serialization;
8-
using System.Threading;
97

108
namespace DCL.SceneLoadingScreens
119
{
@@ -30,7 +28,7 @@ public TipsFromFeatureFlagDecorator(ISceneTipsProvider legacyTips)
3028
this.legacyTips = legacyTips;
3129
}
3230

33-
public async UniTask<SceneTips> GetAsync(CancellationToken ct)
31+
public SceneTips Get()
3432
{
3533
if (!featureFlagChecked)
3634
{
@@ -42,7 +40,7 @@ public async UniTask<SceneTips> GetAsync(CancellationToken ct)
4240
featureFlagChecked = true;
4341
}
4442

45-
SceneTips originTips = await legacyTips.GetAsync(ct);
43+
SceneTips originTips = legacyTips.Get();
4644

4745
if (audienceTipsParseSuccess)
4846
{

Explorer/Assets/DCL/SceneLoadingScreens/UnityLocalizationSceneTipsProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public async UniTask InitializeAsync(CancellationToken ct)
4646
fallbackTips = Get(tipsTable, imagesTable, ct);
4747
}
4848

49-
public async UniTask<SceneTips> GetAsync(CancellationToken ct) =>
49+
public SceneTips Get() =>
5050

5151
// TODO: we will need specific scene tips in the future, but its disabled at the moment
5252
/*StringTable tipsTable = await tipsDatabase.GetTableAsync($"LoadingSceneTips-{parcelCoord.x},{parcelCoord.y}").Task

0 commit comments

Comments
 (0)