1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-22 01:27:29 +08:00
osu-lazer/osu.Game/Screens/Play/SubmittingPlayer.cs
Bartłomiej Dach 6948035a3c
Ensure score submission attempt completion before notifying spectator server when exiting play early
When a `SubmittingPlayer` gameplay session ends with the successful
completion of a beatmap, `PrepareScoreForResultsAsync()` ensures that
the score submission request is sent to and responded to by osu-web
before calling `ISpectatorClient.EndPlaying()`.

While previously this was mostly an implementation detail, this becomes
important when considering that more and more server-side flows (replay
upload, notifying about score processing completion) hook into
`EndPlaying()`, and assume that by the point that message arrives at
osu-spectator-server, the score has already been submitted and has been
assigned a score ID that corresponds to the score submission token.

As it turns out, in the early-exit path (when the user exits the play
midway through, retries, or just fails), the same ordering guarantees
were not provided. The score's submission ran concurrently to the
spectator client `EndPlaying()` call, therefore creating a network
race. osu-server-spectator components that implciitly relied on the
ordering provided by the happy path, could therefore fail to unmap the
score submission token to a score ID.

Note that as written, the osu-server-spectator replay upload flow is
not really affected by this, as it self-corrects by essentially polling
the database and trying to unmap the score submission token to a score
ID for up to 30 seconds. However, this change would have the benefit of
reducing the polls required in such cases to just one DB retrieval.
2022-12-21 22:23:26 +01:00

222 lines
7.7 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Linq;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Screens.Play
{
/// <summary>
/// A player instance which supports submitting scores to an online store.
/// </summary>
public abstract partial class SubmittingPlayer : Player
{
/// <summary>
/// The token to be used for the current submission. This is fetched via a request created by <see cref="CreateTokenRequest"/>.
/// </summary>
private long? token;
[Resolved]
private IAPIProvider api { get; set; }
[Resolved]
private SpectatorClient spectatorClient { get; set; }
private TaskCompletionSource<bool> scoreSubmissionSource;
protected SubmittingPlayer(PlayerConfiguration configuration = null)
: base(configuration)
{
}
protected override void LoadAsyncComplete()
{
base.LoadAsyncComplete();
handleTokenRetrieval();
}
private bool handleTokenRetrieval()
{
// Token request construction should happen post-load to allow derived classes to potentially prepare DI backings that are used to create the request.
var tcs = new TaskCompletionSource<bool>();
if (Mods.Value.Any(m => !m.UserPlayable))
{
handleTokenFailure(new InvalidOperationException("Non-user playable mod selected."));
return false;
}
if (!api.IsLoggedIn)
{
handleTokenFailure(new InvalidOperationException("API is not online."));
return false;
}
var req = CreateTokenRequest();
if (req == null)
{
handleTokenFailure(new InvalidOperationException("Request could not be constructed."));
return false;
}
req.Success += r =>
{
Logger.Log($"Score submission token retrieved ({r.ID})");
token = r.ID;
tcs.SetResult(true);
};
req.Failure += handleTokenFailure;
api.Queue(req);
// Generally a timeout would not happen here as APIAccess will timeout first.
if (!tcs.Task.Wait(30000))
req.TriggerFailure(new InvalidOperationException("Token retrieval timed out (request never run)"));
return true;
void handleTokenFailure(Exception exception)
{
tcs.SetResult(false);
if (HandleTokenRetrievalFailure(exception))
{
if (string.IsNullOrEmpty(exception.Message))
Logger.Error(exception, "Failed to retrieve a score submission token.");
else
Logger.Log($"You are not able to submit a score: {exception.Message}", level: LogLevel.Important);
Schedule(() =>
{
ValidForResume = false;
this.Exit();
});
}
else
{
// Gameplay is allowed to continue, but we still should keep track of the error.
// In the future, this should be visible to the user in some way.
Logger.Log($"Score submission token retrieval failed ({exception.Message})");
}
}
}
/// <summary>
/// Called when a token could not be retrieved for submission.
/// </summary>
/// <param name="exception">The error causing the failure.</param>
/// <returns>Whether gameplay should be immediately exited as a result. Returning false allows the gameplay session to continue. Defaults to true.</returns>
protected virtual bool HandleTokenRetrievalFailure(Exception exception) => true;
protected override async Task PrepareScoreForResultsAsync(Score score)
{
await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false);
score.ScoreInfo.Date = DateTimeOffset.Now;
await submitScore(score).ConfigureAwait(false);
spectatorClient.EndPlaying(GameplayState);
}
[Resolved]
private RealmAccess realm { get; set; }
protected override void StartGameplay()
{
base.StartGameplay();
// User expectation is that last played should be updated when entering the gameplay loop
// from multiplayer / playlists / solo.
realm.WriteAsync(r =>
{
var realmBeatmap = r.Find<BeatmapInfo>(Beatmap.Value.BeatmapInfo.ID);
if (realmBeatmap != null)
realmBeatmap.LastPlayed = DateTimeOffset.Now;
});
spectatorClient.BeginPlaying(token, GameplayState, Score);
}
public override bool OnExiting(ScreenExitEvent e)
{
bool exiting = base.OnExiting(e);
if (LoadedBeatmapSuccessfully)
{
Task.Run(async () =>
{
await submitScore(Score.DeepClone()).ConfigureAwait(false);
spectatorClient.EndPlaying(GameplayState);
}).FireAndForget();
}
return exiting;
}
/// <summary>
/// Construct a request to be used for retrieval of the score token.
/// Can return null, at which point <see cref="HandleTokenRetrievalFailure"/> will be fired.
/// </summary>
[CanBeNull]
protected abstract APIRequest<APIScoreToken> CreateTokenRequest();
/// <summary>
/// Construct a request to submit the score.
/// Will only be invoked if the request constructed via <see cref="CreateTokenRequest"/> was successful.
/// </summary>
/// <param name="score">The score to be submitted.</param>
/// <param name="token">The submission token.</param>
protected abstract APIRequest<MultiplayerScore> CreateSubmissionRequest(Score score, long token);
private Task submitScore(Score score)
{
// token may be null if the request failed but gameplay was still allowed (see HandleTokenRetrievalFailure).
if (token == null)
return Task.CompletedTask;
if (scoreSubmissionSource != null)
return scoreSubmissionSource.Task;
// if the user never hit anything, this score should not be counted in any way.
if (!score.ScoreInfo.Statistics.Any(s => s.Key.IsHit() && s.Value > 0))
return Task.CompletedTask;
scoreSubmissionSource = new TaskCompletionSource<bool>();
var request = CreateSubmissionRequest(score, token.Value);
request.Success += s =>
{
score.ScoreInfo.OnlineID = s.ID;
score.ScoreInfo.Position = s.Position;
scoreSubmissionSource.SetResult(true);
};
request.Failure += e =>
{
Logger.Error(e, $"Failed to submit score ({e.Message})");
scoreSubmissionSource.SetResult(false);
};
api.Queue(request);
return scoreSubmissionSource.Task;
}
}
}