From b6701dd57892f4f585adb1494388768538fb69cc Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 2 Oct 2022 15:29:53 +0300
Subject: [PATCH 1/3] Add failing test case

---
 .../Gameplay/TestSceneStoryboardWithOutro.cs  | 26 ++++++++++++++++---
 1 file changed, 23 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs
index e2c825df0b..a26a7e97be 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs
@@ -32,6 +32,7 @@ namespace osu.Game.Tests.Visual.Gameplay
 
         protected new OutroPlayer Player => (OutroPlayer)base.Player;
 
+        private double currentBeatmapDuration;
         private double currentStoryboardDuration;
 
         private bool showResults = true;
@@ -45,7 +46,8 @@ namespace osu.Game.Tests.Visual.Gameplay
             AddStep("enable storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, true));
             AddStep("set dim level to 0", () => LocalConfig.SetValue<double>(OsuSetting.DimLevel, 0));
             AddStep("reset fail conditions", () => currentFailConditions = (_, _) => false);
-            AddStep("set storyboard duration to 2s", () => currentStoryboardDuration = 2000);
+            AddStep("set beatmap duration to 0s", () => currentBeatmapDuration = 0);
+            AddStep("set storyboard duration to 8s", () => currentStoryboardDuration = 8000);
             AddStep("set ShowResults = true", () => showResults = true);
         }
 
@@ -151,6 +153,24 @@ namespace osu.Game.Tests.Visual.Gameplay
             AddAssert("player exited", () => Stack.CurrentScreen == null);
         }
 
+        [Test]
+        public void TestPerformExitAfterOutro()
+        {
+            CreateTest(() =>
+            {
+                AddStep("set beatmap duration to 4s", () => currentBeatmapDuration = 4000);
+                AddStep("set storyboard duration to 1s", () => currentStoryboardDuration = 1000);
+            });
+
+            AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration);
+            AddStep("exit via pause", () => Player.ExitViaPause());
+            AddAssert("player paused", () => !Player.IsResuming);
+
+            AddStep("resume player", () => Player.Resume());
+            AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value);
+            AddUntilStep("wait for score shown", () => Player.IsScoreShown);
+        }
+
         protected override bool AllowFail => true;
 
         protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
@@ -160,7 +180,7 @@ namespace osu.Game.Tests.Visual.Gameplay
         protected override IBeatmap CreateBeatmap(RulesetInfo ruleset)
         {
             var beatmap = new Beatmap();
-            beatmap.HitObjects.Add(new HitCircle());
+            beatmap.HitObjects.Add(new HitCircle { StartTime = currentBeatmapDuration });
             return beatmap;
         }
 
@@ -189,7 +209,7 @@ namespace osu.Game.Tests.Visual.Gameplay
             private event Func<HealthProcessor, JudgementResult, bool> failConditions;
 
             public OutroPlayer(Func<HealthProcessor, JudgementResult, bool> failConditions, bool showResults = true)
-                : base(false, showResults)
+                : base(showResults: showResults)
             {
                 this.failConditions = failConditions;
             }

From 59728b0ccb78fc7394cc15016cfe1c55fa45884b Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 2 Oct 2022 15:30:06 +0300
Subject: [PATCH 2/3] Fix results display delegate potentially cancelled while
 not exiting

---
 osu.Game/Screens/Play/Player.cs | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index e31e18046e..9bc784411a 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -566,9 +566,6 @@ namespace osu.Game.Screens.Play
         /// </param>
         protected void PerformExit(bool showDialogFirst)
         {
-            // if an exit has been requested, cancel any pending completion (the user has shown intention to exit).
-            resultsDisplayDelegate?.Cancel();
-
             // there is a chance that an exit request occurs after the transition to results has already started.
             // even in such a case, the user has shown intent, so forcefully return to this screen (to proceed with the upwards exit process).
             if (!this.IsCurrentScreen())
@@ -603,6 +600,9 @@ namespace osu.Game.Screens.Play
                 }
             }
 
+            // if an exit has been requested, cancel any pending completion (the user has shown intention to exit).
+            resultsDisplayDelegate?.Cancel();
+
             // The actual exit is performed if
             // - the pause / fail dialog was not requested
             // - the pause / fail dialog was requested but is already displayed (user showing intention to exit).

From a810afafb39752affe1e8f59deef4afc36ed095e Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 2 Oct 2022 15:37:56 +0300
Subject: [PATCH 3/3] Reschedule results display delegate to avoid potential
 softlocks in the future

---
 osu.Game/Screens/Play/Player.cs | 10 +---------
 1 file changed, 1 insertion(+), 9 deletions(-)

diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index 9bc784411a..7721d5b912 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -780,19 +780,11 @@ namespace osu.Game.Screens.Play
         /// </summary>
         /// <remarks>
         /// A final display will only occur once all work is completed in <see cref="PrepareScoreForResultsAsync"/>. This means that even after calling this method, the results screen will never be shown until <see cref="JudgementProcessor.HasCompleted">ScoreProcessor.HasCompleted</see> becomes <see langword="true"/>.
-        ///
-        /// Calling this method multiple times will have no effect.
         /// </remarks>
         /// <param name="withDelay">Whether a minimum delay (<see cref="RESULTS_DISPLAY_DELAY"/>) should be added before the screen is displayed.</param>
         private void progressToResults(bool withDelay)
         {
-            if (resultsDisplayDelegate != null)
-                // Note that if progressToResults is called one withDelay=true and then withDelay=false, this no-delay timing will not be
-                // accounted for. shouldn't be a huge concern (a user pressing the skip button after a results progression has already been queued
-                // may take x00 more milliseconds than expected in the very rare edge case).
-                //
-                // If required we can handle this more correctly by rescheduling here.
-                return;
+            resultsDisplayDelegate?.Cancel();
 
             double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0;