From 51b4e897738e4730c0582af6ebcd52a790b741b1 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 5 May 2026 16:06:00 +0900 Subject: [PATCH] Eagerly connect to latest server instance for best online experience (#37506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- osu.Game/Online/IStatefulUserHubClient.cs | 13 +++++ osu.Game/Online/Metadata/MetadataClient.cs | 27 +++++++++- .../Online/Metadata/OnlineMetadataClient.cs | 27 +++++++--- .../Online/Multiplayer/MultiplayerClient.cs | 52 ++++++++++--------- .../MultiplayerClientExtensions.cs | 39 ++++++++++++++ .../MultiplayerInvitationNotification.cs | 21 ++++++++ .../Multiplayer/OnlineMultiplayerClient.cs | 29 ++++++----- .../Online/Spectator/OnlineSpectatorClient.cs | 15 +++--- osu.Game/Online/Spectator/SpectatorClient.cs | 46 ++++++++++------ .../Visual/Metadata/TestMetadataClient.cs | 10 +++- .../Multiplayer/TestMultiplayerClient.cs | 9 +++- .../Visual/Spectator/TestSpectatorClient.cs | 10 +++- 12 files changed, 224 insertions(+), 74 deletions(-) create mode 100644 osu.Game/Online/Multiplayer/MultiplayerInvitationNotification.cs diff --git a/osu.Game/Online/IStatefulUserHubClient.cs b/osu.Game/Online/IStatefulUserHubClient.cs index e931e3c862..3b0959362f 100644 --- a/osu.Game/Online/IStatefulUserHubClient.cs +++ b/osu.Game/Online/IStatefulUserHubClient.cs @@ -26,5 +26,18 @@ namespace osu.Game.Online /// /// Task DisconnectRequested(); + + /// + /// Invoked when server begins a shutdown sequence. + /// + /// + /// 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). + /// + Task ServerShuttingDown(); } } diff --git a/osu.Game/Online/Metadata/MetadataClient.cs b/osu.Game/Online/Metadata/MetadataClient.cs index e70fe6d3bb..18e5c92b71 100644 --- a/osu.Game/Online/Metadata/MetadataClient.cs +++ b/osu.Game/Online/Metadata/MetadataClient.cs @@ -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 IsConnected { get; } + /// + /// A list of all watched multiplayer rooms (see ). + /// + protected readonly HashSet WatchedRooms = new HashSet(); + [Resolved] private IAPIProvider api { get; set; } = null!; @@ -179,11 +185,28 @@ namespace osu.Game.Online.Metadata #region Disconnection handling + /// + /// Invoked just prior to disconnection. + /// 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; } diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index a30268d7bb..6e4f7e4d6d 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -69,7 +69,8 @@ namespace osu.Game.Online.Metadata connection.On(nameof(IMetadataClient.FriendPresenceUpdated), ((IMetadataClient)this).FriendPresenceUpdated); connection.On(nameof(IMetadataClient.DailyChallengeUpdated), ((IMetadataClient)this).DailyChallengeUpdated); connection.On(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(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); + } } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index cc6d276c88..341739dbed 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -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 /// public event Action? ResultsReady; - /// - /// Invoked just prior to disconnection requested by the server via . - /// - public event Action? Disconnecting; - public event Action? CountdownStarted; public event Action? 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); /// @@ -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 + + /// + /// Invoked just prior to disconnection. + /// + 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 } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs index 83d9e64a36..71dd527d00 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs @@ -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 } }); + /// + /// Start a background process to disconnect/reconnect as soon as a specific condition is met. + /// + /// + /// If a reconnect happens via another means, this will abort attempts. + /// We only want to reconnect once. + /// + /// The client to operate on. + /// Connected state of client. + /// The condition which should be true to continue with the shutdown. + /// The method to run to perform the reconnect. + public static void ReconnectWhenReady(this IStatefulUserHubClient client, IBindable isConnected, Func readyFunction, Func 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) diff --git a/osu.Game/Online/Multiplayer/MultiplayerInvitationNotification.cs b/osu.Game/Online/Multiplayer/MultiplayerInvitationNotification.cs new file mode 100644 index 0000000000..d8ec230ad2 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerInvitationNotification.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . 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)) + { + } + } +} diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index 4fa8c15749..fedddce45b 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -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(nameof(IRankedPlayClient.RankedPlayCardRevealed), ((IRankedPlayClient)this).RankedPlayCardRevealed); connection.On(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); + } } } diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs index 22f8cfbf2b..0ac77d6b35 100644 --- a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs +++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs @@ -46,6 +46,7 @@ namespace osu.Game.Online.Spectator connection.On(nameof(ISpectatorClient.UserStartedWatching), ((ISpectatorClient)this).UserStartedWatching); connection.On(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); } } } diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 5c99eb5208..7b62d64b41 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -75,11 +75,6 @@ namespace osu.Game.Online.Spectator /// public event Action? OnUserScoreProcessed; - /// - /// Invoked just prior to disconnection requested by the server via . - /// - public event Action? Disconnecting; - /// /// A dictionary containing all users currently being watched, with the number of watching components for each user. /// @@ -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 + + /// + /// Invoked just prior to disconnection. + /// + 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 } } diff --git a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs index 5ded0601df..0aadd7dddc 100644 --- a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs +++ b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs @@ -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; } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 14b308e905..17455c1b43 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -824,9 +824,14 @@ namespace osu.Game.Tests.Visual.Multiplayer return MessagePackSerializer.Deserialize(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; } diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index 0eb1e7c285..22a9c4a5d6 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -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; } } }