1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-13 19:54:15 +08:00

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.
This commit is contained in:
Dean Herbert
2026-05-05 16:06:00 +09:00
committed by GitHub
Unverified
parent 80a29eb6a2
commit 51b4e89773
12 changed files with 224 additions and 74 deletions
+13
View File
@@ -26,5 +26,18 @@ namespace osu.Game.Online
/// </list>
/// </remarks>
Task DisconnectRequested();
/// <summary>
/// Invoked when server begins a shutdown sequence.
/// </summary>
/// <remarks>
/// Server shutdowns are graceful.
///
/// This will fire with hours of notice for clients to do what they need to and subsequently
/// disconnect. It's in the client's best interest to switch over to the new hubs as soon as
/// it can, so that the user can be on the same server as the majority of others (and avoid a
/// "server split" scenario where users are split across multiple shutting-down hubs).
/// </remarks>
Task ServerShuttingDown();
}
}
+25 -2
View File
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -19,6 +20,11 @@ namespace osu.Game.Online.Metadata
{
public abstract IBindable<bool> IsConnected { get; }
/// <summary>
/// A list of all watched multiplayer rooms (see <see cref="BeginWatchingMultiplayerRoom"/>).
/// </summary>
protected readonly HashSet<long> WatchedRooms = new HashSet<long>();
[Resolved]
private IAPIProvider api { get; set; } = null!;
@@ -179,11 +185,28 @@ namespace osu.Game.Online.Metadata
#region Disconnection handling
/// <summary>
/// Invoked just prior to disconnection.
/// </summary>
public event Action? Disconnecting;
public virtual Task DisconnectRequested()
public abstract Task Reconnect();
protected abstract Task DisconnectInternal();
Task IStatefulUserHubClient.DisconnectRequested()
{
Schedule(() => Disconnecting?.Invoke());
Schedule(() =>
{
Disconnecting?.Invoke();
DisconnectInternal().FireAndForget();
});
return Task.CompletedTask;
}
Task IStatefulUserHubClient.ServerShuttingDown()
{
this.ReconnectWhenReady(IsConnected, () => WatchedRooms.Count == 0, Reconnect);
return Task.CompletedTask;
}
@@ -69,7 +69,8 @@ namespace osu.Game.Online.Metadata
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), ((IMetadataClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.ServerShuttingDown), ((IStatefulUserHubClient)this).ServerShuttingDown);
};
IsConnected.BindTo(connector.IsConnected);
@@ -109,6 +110,7 @@ namespace osu.Game.Online.Metadata
{
userPresences.Clear();
friendPresences.Clear();
WatchedRooms.Clear();
dailyChallengeInfo.Value = null;
localUserPresence = default;
});
@@ -272,6 +274,8 @@ namespace osu.Game.Online.Metadata
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);
@@ -283,6 +287,8 @@ namespace osu.Game.Online.Metadata
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);
@@ -297,17 +303,22 @@ namespace osu.Game.Online.Metadata
await connection.InvokeAsync(nameof(IMetadataServer.RefreshFriends)).ConfigureAwait(false);
}
public override async Task DisconnectRequested()
{
await base.DisconnectRequested().ConfigureAwait(false);
if (connector != null)
await connector.Disconnect().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);
}
}
}
@@ -11,9 +11,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Database;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
@@ -117,11 +115,6 @@ namespace osu.Game.Online.Multiplayer
/// </summary>
public event Action? ResultsReady;
/// <summary>
/// Invoked just prior to disconnection requested by the server via <see cref="IStatefulUserHubClient.DisconnectRequested"/>.
/// </summary>
public event Action? Disconnecting;
public event Action<MultiplayerCountdown>? CountdownStarted;
public event Action<MultiplayerCountdown>? CountdownStopped;
@@ -490,8 +483,6 @@ namespace osu.Game.Online.Multiplayer
public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
public abstract Task DisconnectInternal();
public abstract Task ChangeUserStyle(int? beatmapId, int? rulesetId);
/// <summary>
@@ -1082,15 +1073,7 @@ namespace osu.Game.Online.Multiplayer
});
}
Task IStatefulUserHubClient.DisconnectRequested()
{
Schedule(() =>
{
Disconnecting?.Invoke();
DisconnectInternal();
});
return Task.CompletedTask;
}
#region Matchmaking / Ranked Play
Task IMatchmakingClient.MatchmakingQueueJoined()
{
@@ -1240,14 +1223,35 @@ namespace osu.Game.Online.Multiplayer
public abstract Task MatchmakingSkipToNextStage();
private partial class MultiplayerInvitationNotification : UserAvatarNotification
{
protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times;
#endregion
public MultiplayerInvitationNotification(APIUser user, Room room)
: base(user, NotificationsStrings.InvitedYouToTheMultiplayer(user.Username, room.Name))
#region Disconnection handling
/// <summary>
/// Invoked just prior to disconnection.
/// </summary>
public event Action? Disconnecting;
protected abstract Task DisconnectInternal();
public abstract Task Reconnect();
Task IStatefulUserHubClient.DisconnectRequested()
{
Schedule(() =>
{
}
Disconnecting?.Invoke();
DisconnectInternal().FireAndForget();
});
return Task.CompletedTask;
}
Task IStatefulUserHubClient.ServerShuttingDown()
{
this.ReconnectWhenReady(IsConnected, () => room == null, Reconnect);
return Task.CompletedTask;
}
#endregion
}
}
@@ -5,7 +5,9 @@ using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ExceptionExtensions;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Logging;
using osu.Game.Utils;
@@ -43,6 +45,43 @@ namespace osu.Game.Online.Multiplayer
}
});
/// <summary>
/// Start a background process to disconnect/reconnect as soon as a specific condition is met.
/// </summary>
/// <remarks>
/// If a reconnect happens via another means, this will abort attempts.
/// We only want to reconnect once.
/// </remarks>
/// <param name="client">The client to operate on.</param>
/// <param name="isConnected">Connected state of client.</param>
/// <param name="readyFunction">The condition which should be <c>true</c> to continue with the shutdown.</param>
/// <param name="reconnectFunction">The method to run to perform the reconnect.</param>
public static void ReconnectWhenReady(this IStatefulUserHubClient client, IBindable<bool> isConnected, Func<bool> readyFunction, Func<Task> reconnectFunction)
{
Task.Run(async () =>
{
bool didReconnect = false;
var connected = isConnected.GetBoundCopy();
connected.ValueChanged += _ => didReconnect = true;
string clientName = client.GetType().ReadableName();
Logger.Log($"{clientName} has signalled shutdown");
while (!readyFunction())
{
Logger.Log($"{clientName} shutdown waiting for idle conditions...");
await Task.Delay(10000).ConfigureAwait(false);
}
Logger.Log($"{clientName} disconnecting due to shutdown signal");
if (!didReconnect)
await reconnectFunction().ConfigureAwait(false);
connected.UnbindAll();
}).FireAndForget();
}
public static string? GetHubExceptionMessage(this Exception exception)
{
if (exception is HubException hubException)
@@ -0,0 +1,21 @@
// 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 osu.Framework.Graphics.Sprites;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Notifications;
namespace osu.Game.Online.Multiplayer
{
public partial class MultiplayerInvitationNotification : UserAvatarNotification
{
protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times;
public MultiplayerInvitationNotification(APIUser user, Room room)
: base(user, NotificationsStrings.InvitedYouToTheMultiplayer(user.Username, room.Name))
{
}
}
}
@@ -10,15 +10,15 @@ using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Notifications;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Matchmaking.Requests;
using osu.Game.Online.Matchmaking.Responses;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Notifications;
namespace osu.Game.Online.Multiplayer
{
@@ -92,7 +92,8 @@ namespace osu.Game.Online.Multiplayer
connection.On<RankedPlayCardItem, MultiplayerPlaylistItem>(nameof(IRankedPlayClient.RankedPlayCardRevealed), ((IRankedPlayClient)this).RankedPlayCardRevealed);
connection.On<RankedPlayCardItem>(nameof(IRankedPlayClient.RankedPlayCardPlayed), ((IRankedPlayClient)this).RankedPlayCardPlayed);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.ServerShuttingDown), ((IStatefulUserHubClient)this).ServerShuttingDown);
};
IsConnected.BindTo(connector.IsConnected);
@@ -335,14 +336,6 @@ namespace osu.Game.Online.Multiplayer
return connection.InvokeAsync(nameof(IMultiplayerServer.VoteToSkipIntro));
}
public override Task DisconnectInternal()
{
if (connector == null)
return Task.CompletedTask;
return connector.Disconnect();
}
public override Task DiscardCards(RankedPlayCardItem[] cards)
{
if (!IsConnected.Value)
@@ -467,5 +460,17 @@ namespace osu.Game.Online.Multiplayer
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);
}
}
}
@@ -46,6 +46,7 @@ namespace osu.Game.Online.Spectator
connection.On<SpectatorUser[]>(nameof(ISpectatorClient.UserStartedWatching), ((ISpectatorClient)this).UserStartedWatching);
connection.On<int>(nameof(ISpectatorClient.UserEndedWatching), ((ISpectatorClient)this).UserEndedWatching);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.ServerShuttingDown), ((IStatefulUserHubClient)this).ServerShuttingDown);
};
IsConnected.BindTo(connector.IsConnected);
@@ -122,14 +123,16 @@ namespace osu.Game.Online.Spectator
return connection.InvokeAsync(nameof(ISpectatorServer.EndWatchingUser), userId);
}
public override async Task Reconnect()
{
if (connector != null)
await connector.Reconnect().ConfigureAwait(false);
}
protected override async Task DisconnectInternal()
{
await base.DisconnectInternal().ConfigureAwait(false);
if (connector == null)
return;
await connector.Disconnect().ConfigureAwait(false);
if (connector != null)
await connector.Disconnect().ConfigureAwait(false);
}
}
}
+29 -17
View File
@@ -75,11 +75,6 @@ namespace osu.Game.Online.Spectator
/// </summary>
public event Action<int, long>? OnUserScoreProcessed;
/// <summary>
/// Invoked just prior to disconnection requested by the server via <see cref="IStatefulUserHubClient.DisconnectRequested"/>.
/// </summary>
public event Action? Disconnecting;
/// <summary>
/// A dictionary containing all users currently being watched, with the number of watching components for each user.
/// </summary>
@@ -203,12 +198,6 @@ namespace osu.Game.Online.Spectator
return Task.CompletedTask;
}
Task IStatefulUserHubClient.DisconnectRequested()
{
Schedule(() => DisconnectInternal().FireAndForget());
return Task.CompletedTask;
}
public void BeginPlaying(long? scoreToken, GameplayState state, Score score)
{
// This schedule is only here to match the one below in `EndPlaying`.
@@ -373,12 +362,6 @@ namespace osu.Game.Online.Spectator
protected abstract Task StopWatchingUserInternal(int userId);
protected virtual Task DisconnectInternal()
{
Disconnecting?.Invoke();
return Task.CompletedTask;
}
protected override void Update()
{
base.Update();
@@ -446,5 +429,34 @@ namespace osu.Game.Online.Spectator
});
});
}
#region Disconnection handling
/// <summary>
/// Invoked just prior to disconnection.
/// </summary>
public event Action? Disconnecting;
protected abstract Task DisconnectInternal();
public abstract Task Reconnect();
Task IStatefulUserHubClient.DisconnectRequested()
{
Schedule(() =>
{
Disconnecting?.Invoke();
DisconnectInternal().FireAndForget();
});
return Task.CompletedTask;
}
Task IStatefulUserHubClient.ServerShuttingDown()
{
this.ReconnectWhenReady(IsConnected, () => watchedUsersRefCounts.Count == 0, Reconnect);
return Task.CompletedTask;
}
#endregion
}
}
@@ -136,9 +136,17 @@ namespace osu.Game.Tests.Visual.Metadata
dailyChallengeInfo.Value = null;
}
public void Reconnect()
public override Task Reconnect()
{
isConnected.Value = true;
return Task.CompletedTask;
}
protected override Task DisconnectInternal()
{
Disconnect();
return Task.CompletedTask;
}
}
}
@@ -824,9 +824,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
return MessagePackSerializer.Deserialize<T>(serialized, SignalRUnionWorkaroundResolver.OPTIONS);
}
public override Task DisconnectInternal()
protected override Task DisconnectInternal()
{
Disconnect();
return Task.CompletedTask;
}
public override Task Reconnect()
{
isConnected.Value = false;
return Task.CompletedTask;
}
@@ -204,10 +204,16 @@ namespace osu.Game.Tests.Visual.Spectator
});
}
protected override async Task DisconnectInternal()
protected override Task DisconnectInternal()
{
await base.DisconnectInternal().ConfigureAwait(false);
isConnected.Value = false;
return Task.CompletedTask;
}
public override Task Reconnect()
{
isConnected.Value = true;
return Task.CompletedTask;
}
}
}