// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable enable using System; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using osu.Framework; using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Game.Online.API; namespace osu.Game.Online { /// /// A component that manages the life cycle of a connection to a SignalR Hub. /// public class HubClientConnector : IDisposable { /// /// Invoked whenever a new hub connection is built. /// public Action? ConfigureConnection; private readonly string clientName; private readonly string endpoint; private readonly IAPIProvider? api; /// /// The current connection opened by this connector. /// public HubConnection? CurrentConnection { get; private set; } /// /// Whether this is connected to the hub, use to access the connection, if this is true. /// public IBindable IsConnected => isConnected; private readonly Bindable isConnected = new Bindable(); private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1); private CancellationTokenSource connectCancelSource = new CancellationTokenSource(); private readonly IBindable apiState = new Bindable(); /// /// Constructs a new . /// /// The name of the client this connector connects for, used for logging. /// The endpoint to the hub. /// An API provider used to react to connection state changes, or null to not establish connection at all (for testing purposes). public HubClientConnector(string clientName, string endpoint, IAPIProvider? api) { this.clientName = clientName; this.endpoint = endpoint; this.api = api; if (api != null) { apiState.BindTo(api.State); apiState.BindValueChanged(state => { switch (state.NewValue) { case APIState.Failing: case APIState.Offline: Task.Run(() => disconnect(true)); break; case APIState.Online: Task.Run(connect); break; } }, true); } } private async Task connect() { cancelExistingConnect(); if (!await connectionLock.WaitAsync(10000)) throw new TimeoutException("Could not obtain a lock to connect. A previous attempt is likely stuck."); try { while (apiState.Value == APIState.Online) { // ensure any previous connection was disposed. // this will also create a new cancellation token source. await disconnect(false); // this token will be valid for the scope of this connection. // if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere. var cancellationToken = connectCancelSource.Token; cancellationToken.ThrowIfCancellationRequested(); Logger.Log($"{clientName} connecting...", LoggingTarget.Network); try { // importantly, rebuild the connection each attempt to get an updated access token. CurrentConnection = buildConnection(cancellationToken); await CurrentConnection.StartAsync(cancellationToken); Logger.Log($"{clientName} connected!", LoggingTarget.Network); isConnected.Value = true; return; } catch (OperationCanceledException) { //connection process was cancelled. throw; } catch (Exception e) { Logger.Log($"{clientName} connection error: {e}", LoggingTarget.Network); // retry on any failure. await Task.Delay(5000, cancellationToken); } } } finally { connectionLock.Release(); } } private HubConnection buildConnection(CancellationToken cancellationToken) { Debug.Assert(api != null); var builder = new HubConnectionBuilder() .WithUrl(endpoint, options => { options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); }); if (RuntimeInfo.SupportsJIT) builder.AddMessagePackProtocol(); else { // eventually we will precompile resolvers for messagepack, but this isn't working currently // see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308. builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }); } var newConnection = builder.Build(); ConfigureConnection?.Invoke(newConnection); newConnection.Closed += ex => onConnectionClosed(ex, cancellationToken); return newConnection; } private Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken) { isConnected.Value = false; Logger.Log(ex != null ? $"{clientName} lost connection: {ex}" : $"{clientName} disconnected", LoggingTarget.Network); // make sure a disconnect wasn't triggered (and this is still the active connection). if (!cancellationToken.IsCancellationRequested) Task.Run(connect, default); return Task.CompletedTask; } private async Task disconnect(bool takeLock) { cancelExistingConnect(); if (takeLock) { if (!await connectionLock.WaitAsync(10000)) throw new TimeoutException("Could not obtain a lock to disconnect. A previous attempt is likely stuck."); } try { if (CurrentConnection != null) await CurrentConnection.DisposeAsync(); } finally { CurrentConnection = null; if (takeLock) connectionLock.Release(); } } private void cancelExistingConnect() { connectCancelSource.Cancel(); connectCancelSource = new CancellationTokenSource(); } public override string ToString() => $"Connector for {clientName} ({(IsConnected.Value ? "connected" : "not connected")}"; public void Dispose() { apiState.UnbindAll(); cancelExistingConnect(); } } }