1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-18 18:15:03 +08:00
Files
osu-lazer/osu.Game/Online/Metadata/OnlineMetadataClient.cs
T
Dean Herbert 51b4e89773 Eagerly connect to latest server instance for best online experience (#37506)
Client side requirements for making the client connect as soon as
possible, based on how the client is being used. This is especially
important with the introduction of ranked play: previously the worst
case scenario would be that you couldn't join a multiplayer room (or
spectate a user) and this was [automatically
handled](https://github.com/ppy/osu/blob/f66e2c432fdb08db46477c4fa08ca74e551d037f/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs#L115-L121)
mostly*, but now, if you leave the game open for a while, you can
potentially be stuck queueing in ranked play with no users remaining on
your server.

Some samples of how this looks follow. Do note that the client is
showing "Server is shutting down" errors. This is only going to happen
in local debug environments – In production, when you reconnect to the
endpoint you will always get a non-shutting-down instance.

Idle scenario:


https://github.com/user-attachments/assets/dd47fdf6-8d49-48e3-a19f-b196a581070b

Non-idle scenario:


https://github.com/user-attachments/assets/dfc8a41a-83fb-4b08-94b4-9595faf88294

* Spectator isn't handled properly, as one example.
2026-05-05 16:06:00 +09:00

325 lines
13 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.
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Game.Configuration;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Users;
namespace osu.Game.Online.Metadata
{
public partial class OnlineMetadataClient : MetadataClient
{
public override IBindable<bool> IsConnected { get; } = new Bindable<bool>();
public override UserPresence LocalUserPresence => localUserPresence;
private UserPresence localUserPresence;
public override IBindableDictionary<int, UserPresence> UserPresences => userPresences;
private readonly BindableDictionary<int, UserPresence> userPresences = new BindableDictionary<int, UserPresence>();
public override IBindableDictionary<int, UserPresence> FriendPresences => friendPresences;
private readonly BindableDictionary<int, UserPresence> friendPresences = new BindableDictionary<int, UserPresence>();
public override IBindable<DailyChallengeInfo?> DailyChallengeInfo => dailyChallengeInfo;
private readonly Bindable<DailyChallengeInfo?> dailyChallengeInfo = new Bindable<DailyChallengeInfo?>();
private readonly string endpoint;
[Resolved]
private IAPIProvider api { get; set; } = null!;
private IHubClientConnector? connector;
private Bindable<int> lastQueueId = null!;
private IBindable<APIUser> localUser = null!;
private IBindable<UserStatus> userStatus = null!;
private IBindable<UserActivity?> userActivity = null!;
private HubConnection? connection => connector?.CurrentConnection;
public OnlineMetadataClient(EndpointConfiguration endpoints)
{
endpoint = endpoints.MetadataUrl;
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config, SessionStatics session)
{
// Importantly, we are intentionally not using MessagePack here to correctly support derived class serialization.
// More information on the limitations / reasoning can be found in osu-server-spectator's initialisation code.
connector = api.GetHubConnector(nameof(OnlineMetadataClient), endpoint);
if (connector != null)
{
connector.ConfigureConnection = connection =>
{
// this is kind of SILLY
// https://github.com/dotnet/aspnetcore/issues/15198
connection.On<BeatmapUpdates>(nameof(IMetadataClient.BeatmapSetsUpdated), ((IMetadataClient)this).BeatmapSetsUpdated);
connection.On<int, UserPresence?>(nameof(IMetadataClient.UserPresenceUpdated), ((IMetadataClient)this).UserPresenceUpdated);
connection.On<int, UserPresence?>(nameof(IMetadataClient.FriendPresenceUpdated), ((IMetadataClient)this).FriendPresenceUpdated);
connection.On<DailyChallengeInfo?>(nameof(IMetadataClient.DailyChallengeUpdated), ((IMetadataClient)this).DailyChallengeUpdated);
connection.On<MultiplayerRoomScoreSetEvent>(nameof(IMetadataClient.MultiplayerRoomScoreSet), ((IMetadataClient)this).MultiplayerRoomScoreSet);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.ServerShuttingDown), ((IStatefulUserHubClient)this).ServerShuttingDown);
};
IsConnected.BindTo(connector.IsConnected);
IsConnected.BindValueChanged(isConnectedChanged, true);
}
localUser = api.LocalUser.GetBoundCopy();
lastQueueId = config.GetBindable<int>(OsuSetting.LastProcessedMetadataId);
userStatus = config.GetBindable<UserStatus>(OsuSetting.UserOnlineStatus);
userActivity = session.GetBindable<UserActivity?>(Static.UserOnlineActivity);
}
protected override void LoadComplete()
{
base.LoadComplete();
userStatus.BindValueChanged(status =>
{
if (localUser.Value is not GuestUser)
UpdateStatus(status.NewValue).FireAndForget();
}, true);
userActivity.BindValueChanged(activity =>
{
if (localUser.Value is not GuestUser)
UpdateActivity(activity.NewValue).FireAndForget();
}, true);
}
private bool catchingUp;
private void isConnectedChanged(ValueChangedEvent<bool> connected)
{
if (!connected.NewValue)
{
Schedule(() =>
{
userPresences.Clear();
friendPresences.Clear();
WatchedRooms.Clear();
dailyChallengeInfo.Value = null;
localUserPresence = default;
});
return;
}
if (IsWatchingUserPresence)
BeginWatchingUserPresenceInternal().FireAndForget();
if (localUser.Value is not GuestUser)
{
UpdateActivity(userActivity.Value).FireAndForget();
UpdateStatus(userStatus.Value).FireAndForget();
}
if (lastQueueId.Value >= 0)
{
catchingUp = true;
Task.Run(async () =>
{
try
{
while (true)
{
Logger.Log($"Requesting catch-up from {lastQueueId.Value}");
var catchUpChanges = await GetChangesSince(lastQueueId.Value).ConfigureAwait(true);
lastQueueId.Value = catchUpChanges.LastProcessedQueueID;
if (catchUpChanges.BeatmapSetIDs.Length == 0)
{
Logger.Log($"Catch-up complete at {lastQueueId.Value}");
break;
}
await ProcessChanges(catchUpChanges.BeatmapSetIDs).ConfigureAwait(true);
}
}
catch (Exception e)
{
Logger.Log($"Error while processing catch-up of metadata ({e.Message})");
}
finally
{
catchingUp = false;
}
});
}
}
public override async Task BeatmapSetsUpdated(BeatmapUpdates updates)
{
Logger.Log($"Received beatmap updates {updates.BeatmapSetIDs.Length} updates with last id {updates.LastProcessedQueueID}");
// If we're still catching up, avoid updating the last ID as it will interfere with catch-up efforts.
if (!catchingUp)
lastQueueId.Value = updates.LastProcessedQueueID;
await ProcessChanges(updates.BeatmapSetIDs).ConfigureAwait(false);
}
public override Task<BeatmapUpdates> GetChangesSince(int queueId)
{
if (connector?.IsConnected.Value != true)
return Task.FromCanceled<BeatmapUpdates>(CancellationToken.None);
Logger.Log($"Requesting any changes since last known queue id {queueId}");
Debug.Assert(connection != null);
return connection.InvokeAsync<BeatmapUpdates>(nameof(IMetadataServer.GetChangesSince), queueId);
}
public override Task UpdateActivity(UserActivity? activity)
{
if (connector?.IsConnected.Value != true)
return Task.FromCanceled(new CancellationToken(true));
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMetadataServer.UpdateActivity), activity);
}
public override Task UpdateStatus(UserStatus? status)
{
if (connector?.IsConnected.Value != true)
return Task.FromCanceled(new CancellationToken(true));
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMetadataServer.UpdateStatus), status);
}
protected override Task BeginWatchingUserPresenceInternal()
{
if (connector?.IsConnected.Value != true)
return Task.CompletedTask;
Logger.Log($@"{nameof(OnlineMetadataClient)} began watching user presence", LoggingTarget.Network);
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingUserPresence));
}
protected override Task EndWatchingUserPresenceInternal()
{
if (connector?.IsConnected.Value != true)
return Task.CompletedTask;
Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network);
// must be scheduled before any remote calls to avoid mis-ordering.
Schedule(() => userPresences.Clear());
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence));
}
public override Task UserPresenceUpdated(int userId, UserPresence? presence)
{
Schedule(() =>
{
if (presence?.Status != null)
{
if (userId == api.LocalUser.Value.OnlineID)
localUserPresence = presence.Value;
userPresences[userId] = presence.Value;
}
else
{
if (userId == api.LocalUser.Value.OnlineID)
localUserPresence = default;
userPresences.Remove(userId);
}
});
return Task.CompletedTask;
}
public override Task FriendPresenceUpdated(int userId, UserPresence? presence)
{
Schedule(() =>
{
if (presence?.Status != null)
friendPresences[userId] = presence.Value;
else
friendPresences.Remove(userId);
});
return Task.CompletedTask;
}
public override Task DailyChallengeUpdated(DailyChallengeInfo? info)
{
Schedule(() => dailyChallengeInfo.Value = info);
return Task.CompletedTask;
}
public override async Task<MultiplayerPlaylistItemStats[]> BeginWatchingMultiplayerRoom(long id)
{
if (connector?.IsConnected.Value != true)
throw new OperationCanceledException();
WatchedRooms.Add(id);
Debug.Assert(connection != null);
var result = await connection.InvokeAsync<MultiplayerPlaylistItemStats[]>(nameof(IMetadataServer.BeginWatchingMultiplayerRoom), id).ConfigureAwait(false);
Logger.Log($@"{nameof(OnlineMetadataClient)} began watching multiplayer room with ID {id}", LoggingTarget.Network);
return result;
}
public override async Task EndWatchingMultiplayerRoom(long id)
{
if (connector?.IsConnected.Value != true)
throw new OperationCanceledException();
WatchedRooms.Remove(id);
Debug.Assert(connection != null);
await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingMultiplayerRoom), id).ConfigureAwait(false);
Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching multiplayer room with ID {id}", LoggingTarget.Network);
}
public override async Task RefreshFriends()
{
if (connector?.IsConnected.Value != true)
throw new OperationCanceledException();
Debug.Assert(connection != null);
await connection.InvokeAsync(nameof(IMetadataServer.RefreshFriends)).ConfigureAwait(false);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
connector?.Dispose();
}
public override async Task Reconnect()
{
if (connector != null)
await connector.Reconnect().ConfigureAwait(false);
}
protected override async Task DisconnectInternal()
{
if (connector != null)
await connector.Disconnect().ConfigureAwait(false);
}
}
}