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; } } }