File: Circuits\CircuitRegistry.cs
Web Access
Project: src\src\Components\Server\src\Microsoft.AspNetCore.Components.Server.csproj (Microsoft.AspNetCore.Components.Server)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
 
namespace Microsoft.AspNetCore.Components.Server.Circuits;
 
/// <summary>
/// <see cref="CircuitRegistry"/> manages the lifetime of a <see cref="CircuitHost"/>.
/// </summary>
/// <remarks>
/// Hosts start off by being registered using <see cref="CircuitHost"/>.
///
/// In the simplest of cases, the client disconnects e.g. the user is done with the application and closes the browser.
/// The server (eventually) learns of the disconnect. The host is transitioned from <see cref="ConnectedCircuits"/> to
/// <see cref="DisconnectedCircuits"/> where it sits with an expiration time. We'll mark the associated <see cref="CircuitClientProxy"/> as disconnected
/// so that consumers of the Circuit know of the current state.
/// Once the entry for the host in <see cref="DisconnectedCircuits"/> expires, we'll dispose off the host.
///
/// The alternate case is when the disconnect was transient, e.g. due to a network failure, and the client attempts to reconnect.
/// We'll attempt to connect it back to the host and the preserved server state, when available. In this event, we do the opposite of
/// what we did during disconnect - transition the host from <see cref="DisconnectedCircuits"/> to <see cref="ConnectedCircuits"/>, and transfer
/// the <see cref="CircuitClientProxy"/> to use the new client instance that attempted to reconnect to the server. Removing the entry from
/// <see cref="DisconnectedCircuits"/> should ensure we no longer have to concern ourselves with entry expiration.
///
/// Knowing when a client disconnected is not an exact science. There's a fair possibility that a client may reconnect before the server realizes.
/// Consequently, we have to account for reconnects and disconnects occuring simultaneously as well as appearing out of order.
/// To manage this, we use a critical section to manage all state transitions.
/// </remarks>
#pragma warning disable CA1852 // Seal internal types
internal partial class CircuitRegistry
#pragma warning restore CA1852 // Seal internal types
{
    private readonly object CircuitRegistryLock = new object();
    private readonly CircuitOptions _options;
    private readonly ILogger _logger;
    private readonly CircuitIdFactory _circuitIdFactory;
    private readonly PostEvictionCallbackRegistration _postEvictionCallback;
 
    public CircuitRegistry(
        IOptions<CircuitOptions> options,
        ILogger<CircuitRegistry> logger,
        CircuitIdFactory CircuitHostFactory)
    {
        _options = options.Value;
        _logger = logger;
        _circuitIdFactory = CircuitHostFactory;
        ConnectedCircuits = new ConcurrentDictionary<CircuitId, CircuitHost>();
 
        DisconnectedCircuits = new MemoryCache(new MemoryCacheOptions
        {
            SizeLimit = _options.DisconnectedCircuitMaxRetained,
        });
 
        _postEvictionCallback = new PostEvictionCallbackRegistration
        {
            EvictionCallback = OnEntryEvicted,
        };
    }
 
    internal ConcurrentDictionary<CircuitId, CircuitHost> ConnectedCircuits { get; }
 
    internal MemoryCache DisconnectedCircuits { get; }
 
    /// <summary>
    /// Registers an active <see cref="CircuitHost"/> with the register.
    /// </summary>
    public void Register(CircuitHost circuitHost)
    {
        if (!ConnectedCircuits.TryAdd(circuitHost.CircuitId, circuitHost))
        {
            // This will likely never happen, except perhaps in unit tests, since CircuitIds are unique.
            throw new ArgumentException($"Circuit with identity {circuitHost.CircuitId} is already registered.");
        }
 
        // Register for unhandled exceptions from the circuit. The registry is responsible for tearing
        // down the circuit on errors.
        circuitHost.UnhandledException += CircuitHost_UnhandledException;
    }
 
    public virtual Task DisconnectAsync(CircuitHost circuitHost, string connectionId)
    {
        Log.CircuitDisconnectStarted(_logger, circuitHost.CircuitId, connectionId);
 
        Task circuitHandlerTask;
        lock (CircuitRegistryLock)
        {
            if (DisconnectCore(circuitHost, connectionId))
            {
                circuitHandlerTask = circuitHost.Renderer.Dispatcher.InvokeAsync(() => circuitHost.OnConnectionDownAsync(default));
            }
            else
            {
                // DisconnectCore may fail to disconnect the circuit if it was previously marked inactive or
                // has been transferred to a new connection. Do not invoke the circuit handlers in this instance.
 
                // We have to do in this instance.
                return Task.CompletedTask;
            }
        }
 
        return circuitHandlerTask;
    }
 
    protected virtual bool DisconnectCore(CircuitHost circuitHost, string connectionId)
    {
        var circuitId = circuitHost.CircuitId;
        if (!ConnectedCircuits.TryGetValue(circuitId, out circuitHost))
        {
            Log.CircuitNotActive(_logger, circuitId);
 
            // Guard: The circuit might already have been marked as inactive.
            return false;
        }
 
        if (!string.Equals(circuitHost.Client.ConnectionId, connectionId, StringComparison.Ordinal))
        {
            Log.CircuitConnectedToDifferentConnection(_logger, circuitId, circuitHost.Client.ConnectionId);
 
            // The circuit is associated with a different connection. One way this could happen is when
            // the client reconnects with a new connection before the OnDisconnect for the older
            // connection is executed. Do nothing
            return false;
        }
 
        var result = ConnectedCircuits.TryRemove(circuitId, out circuitHost);
        Debug.Assert(result, "This operation operates inside of a lock. We expect the previously inspected value to be still here.");
 
        circuitHost.Client.SetDisconnected();
        RegisterDisconnectedCircuit(circuitHost);
 
        Log.CircuitMarkedDisconnected(_logger, circuitId);
 
        return true;
    }
 
    public void RegisterDisconnectedCircuit(CircuitHost circuitHost)
    {
        var cancellationTokenSource = new CancellationTokenSource(_options.DisconnectedCircuitRetentionPeriod);
        var entryOptions = new MemoryCacheEntryOptions
        {
            Size = 1,
            PostEvictionCallbacks = { _postEvictionCallback },
            ExpirationTokens =
                {
                    new CancellationChangeToken(cancellationTokenSource.Token),
                },
        };
 
        var entry = new DisconnectedCircuitEntry(circuitHost, cancellationTokenSource);
        DisconnectedCircuits.Set(circuitHost.CircuitId.Secret, entry, entryOptions);
    }
 
    // ConnectAsync is called from the CircuitHub - but the error handling story is a little bit complicated.
    // We return the circuit from this method, but need to clean up the circuit on failure. So we don't want to
    // throw from this method because we don't want to return a *failed* circuit.
    //
    // The solution is to handle exceptions here, and then return null to represent failure.
    //
    // 1. If the circuit is not found return null
    // 2. If the circuit is found, but fails to connect, we need to dispose it here and return null
    // 3. If everything goes well, return the circuit.
    public virtual async Task<CircuitHost> ConnectAsync(CircuitId circuitId, IClientProxy clientProxy, string connectionId, CancellationToken cancellationToken)
    {
        Log.CircuitConnectStarted(_logger, circuitId);
 
        CircuitHost circuitHost;
        bool previouslyConnected;
 
        Task circuitHandlerTask;
 
        // We don't expect any of the logic inside the lock to throw, or run user code.
        lock (CircuitRegistryLock)
        {
            // Transition the host from disconnected to connected if it's available. In this critical section, we return
            // an existing host if it's currently considered connected or transition a disconnected host to connected.
            // Transferring also wires up the client to the new set.
            (circuitHost, previouslyConnected) = ConnectCore(circuitId, clientProxy, connectionId);
 
            if (circuitHost == null)
            {
                Log.FailedToFindCircuit(_logger, circuitId);
                // Failed to find a matching circuit. Nothing to do here.
                return null;
            }
 
            // CircuitHandler events do not need to be executed inside the critical section, however we
            // a) do not want concurrent execution of handler events i.e. a  OnConnectionDownAsync occuring in tandem with a OnConnectionUpAsync for a single circuit.
            // b) out of order connection-up \ connection-down events e.g. a client that disconnects as soon it finishes reconnecting.
 
            // Dispatch the circuit handlers inside the sync context to ensure the order of execution. CircuitHost executes circuit handlers inside of
            // the sync context.
            circuitHandlerTask = circuitHost.Renderer.Dispatcher.InvokeAsync(async () =>
            {
                if (previouslyConnected)
                {
                    // During reconnects, we may transition from Connect->Connect i.e.without ever having invoking OnConnectionDownAsync during
                    // a formal client disconnect. To allow authors of CircuitHandlers to have reasonable expectations will pair the connection up with a connection down.
                    await circuitHost.OnConnectionDownAsync(cancellationToken);
                }
 
                await circuitHost.OnConnectionUpAsync(cancellationToken);
            });
        }
 
        try
        {
            await circuitHandlerTask;
            Log.ReconnectionSucceeded(_logger, circuitHost.CircuitId);
            return circuitHost;
        }
        catch (Exception ex)
        {
            Log.FailedToReconnectToCircuit(_logger, circuitHost.CircuitId, ex);
            await TerminateAsync(circuitId);
 
            // Return null on failure, because we need to clean up the circuit.
            return null;
        }
    }
 
    protected virtual (CircuitHost circuitHost, bool previouslyConnected) ConnectCore(CircuitId circuitId, IClientProxy clientProxy, string connectionId)
    {
        if (ConnectedCircuits.TryGetValue(circuitId, out var connectedCircuitHost))
        {
            Log.ConnectingToActiveCircuit(_logger, connectedCircuitHost.CircuitId, connectionId);
 
            // The host is still active i.e. the server hasn't detected the client disconnect.
            // However the client reconnected establishing a new connection.
            connectedCircuitHost.Client.Transfer(clientProxy, connectionId);
            return (connectedCircuitHost, true);
        }
 
        if (DisconnectedCircuits.TryGetValue(circuitId.Secret, out DisconnectedCircuitEntry disconnectedEntry))
        {
            Log.ConnectingToDisconnectedCircuit(_logger, disconnectedEntry.CircuitHost.CircuitId, connectionId);
 
            // The host was in disconnected state. Transfer it to ConnectedCircuits so that it's no longer considered disconnected.
            // First discard the CancellationTokenSource so that the cache entry does not expire.
            DisposeTokenSource(disconnectedEntry);
 
            DisconnectedCircuits.Remove(circuitId.Secret);
            ConnectedCircuits.TryAdd(circuitId, disconnectedEntry.CircuitHost);
 
            disconnectedEntry.CircuitHost.Client.Transfer(clientProxy, connectionId);
            return (disconnectedEntry.CircuitHost, false);
        }
 
        return default;
    }
 
    protected virtual void OnEntryEvicted(object key, object value, EvictionReason reason, object state)
    {
        switch (reason)
        {
            case EvictionReason.Expired:
            case EvictionReason.TokenExpired:
            case EvictionReason.Capacity:
                // Kick off the dispose in the background.
                var disconnectedEntry = (DisconnectedCircuitEntry)value;
                Log.CircuitEvicted(_logger, disconnectedEntry.CircuitHost.CircuitId, reason);
                _ = DisposeCircuitEntry(disconnectedEntry);
                break;
 
            case EvictionReason.Removed:
                // The entry was explicitly removed as part of TryGetInactiveCircuit. Nothing to do here.
                return;
 
            default:
                Debug.Fail($"Unexpected {nameof(EvictionReason)} {reason}");
                break;
        }
    }
 
    private async Task DisposeCircuitEntry(DisconnectedCircuitEntry entry)
    {
        DisposeTokenSource(entry);
 
        try
        {
            entry.CircuitHost.UnhandledException -= CircuitHost_UnhandledException;
            await entry.CircuitHost.DisposeAsync();
        }
        catch (Exception ex)
        {
            Log.UnhandledExceptionDisposingCircuitHost(_logger, ex);
        }
    }
 
    private void DisposeTokenSource(DisconnectedCircuitEntry entry)
    {
        try
        {
            entry.TokenSource.Dispose();
        }
        catch (Exception ex)
        {
            Log.ExceptionDisposingTokenSource(_logger, ex);
        }
    }
 
    // We don't expect this to throw. User code only runs inside DisposeAsync and that does its own error handling.
    public ValueTask TerminateAsync(CircuitId circuitId)
    {
        CircuitHost circuitHost;
        DisconnectedCircuitEntry entry = default;
        lock (CircuitRegistryLock)
        {
            if (ConnectedCircuits.TryGetValue(circuitId, out circuitHost) || DisconnectedCircuits.TryGetValue(circuitId.Secret, out entry))
            {
                circuitHost ??= entry.CircuitHost;
                DisconnectedCircuits.Remove(circuitId.Secret);
                ConnectedCircuits.TryRemove(circuitId, out _);
                Log.CircuitDisconnectedPermanently(_logger, circuitHost.CircuitId);
                circuitHost.Client.SetDisconnected();
            }
        }
 
        if (circuitHost != null)
        {
            circuitHost.UnhandledException -= CircuitHost_UnhandledException;
            return circuitHost.DisposeAsync();
        }
 
        return default;
    }
 
    // We don't need to do anything with the exception here, logging and sending exceptions to the client
    // is done inside the circuit host.
    private async void CircuitHost_UnhandledException(object sender, UnhandledExceptionEventArgs e)
    {
        var circuitHost = (CircuitHost)sender;
 
        try
        {
            // This will dispose the circuit and remove it from the registry.
            await TerminateAsync(circuitHost.CircuitId);
        }
        catch (Exception ex)
        {
            // We don't expect TerminateAsync to throw, but we want exceptions here for completeness.
            Log.CircuitExceptionHandlerFailed(_logger, circuitHost.CircuitId, ex);
        }
    }
 
    private readonly struct DisconnectedCircuitEntry
    {
        public DisconnectedCircuitEntry(CircuitHost circuitHost, CancellationTokenSource tokenSource)
        {
            CircuitHost = circuitHost;
            TokenSource = tokenSource;
        }
 
        public CircuitHost CircuitHost { get; }
        public CancellationTokenSource TokenSource { get; }
    }
 
    private static partial class Log
    {
        [LoggerMessage(100, LogLevel.Error, "Unhandled exception disposing circuit host: {Message}", EventName = "ExceptionDisposingCircuit")]
        private static partial void UnhandledExceptionDisposingCircuitHost(ILogger logger, string message, Exception exception);
 
        public static void UnhandledExceptionDisposingCircuitHost(ILogger logger, Exception exception)
            => UnhandledExceptionDisposingCircuitHost(logger, exception.Message, exception);
 
        [LoggerMessage(101, LogLevel.Debug, "Exception thrown when disposing token source: {Message}", EventName = "ExceptionDisposingTokenSource")]
        private static partial void ExceptionDisposingTokenSource(ILogger logger, string message, Exception exception);
 
        public static void ExceptionDisposingTokenSource(ILogger logger, Exception exception)
            => ExceptionDisposingTokenSource(logger, exception.Message, exception);
 
        [LoggerMessage(102, LogLevel.Debug, "Attempting to reconnect to Circuit with secret {CircuitHost}.", EventName = "AttemptingToReconnect")]
        public static partial void CircuitConnectStarted(ILogger logger, CircuitId circuitHost);
 
        [LoggerMessage(104, LogLevel.Debug, "Failed to find a matching circuit for circuit secret {CircuitHost}.", EventName = "FailedToFindCircuit")]
        public static partial void FailedToFindCircuit(ILogger logger, CircuitId circuitHost);
 
        [LoggerMessage(105, LogLevel.Debug, "Transferring active circuit {CircuitId} to connection {ConnectionId}.", EventName = "ConnectingToActiveCircuit")]
        public static partial void ConnectingToActiveCircuit(ILogger logger, CircuitId circuitId, string connectionId);
 
        [LoggerMessage(106, LogLevel.Debug, "Transferring disconnected circuit {CircuitId} to connection {ConnectionId}.", EventName = "ConnectingToDisconnectedCircuit")]
        public static partial void ConnectingToDisconnectedCircuit(ILogger logger, CircuitId circuitId, string connectionId);
 
        [LoggerMessage(107, LogLevel.Debug, "Failed to reconnect to a circuit with id {CircuitId}.", EventName = "FailedToReconnectToCircuit")]
        public static partial void FailedToReconnectToCircuit(ILogger logger, CircuitId circuitId, Exception exception = null);
 
        [LoggerMessage(108, LogLevel.Debug, "Attempting to disconnect circuit with id {CircuitId} from connection {ConnectionId}.", EventName = "CircuitDisconnectStarted")]
        public static partial void CircuitDisconnectStarted(ILogger logger, CircuitId circuitId, string connectionId);
 
        [LoggerMessage(109, LogLevel.Debug, "Failed to disconnect circuit with id {CircuitId}. The circuit is not active.", EventName = "CircuitNotActive")]
        public static partial void CircuitNotActive(ILogger logger, CircuitId circuitId);
 
        [LoggerMessage(110, LogLevel.Debug, "Failed to disconnect circuit with id {CircuitId}. The circuit is connected to {ConnectionId}.", EventName = "CircuitConnectedToDifferentConnection")]
        public static partial void CircuitConnectedToDifferentConnection(ILogger logger, CircuitId circuitId, string connectionId);
 
        [LoggerMessage(111, LogLevel.Debug, "Circuit with id {CircuitId} is disconnected.", EventName = "CircuitMarkedDisconnected")]
        public static partial void CircuitMarkedDisconnected(ILogger logger, CircuitId circuitId);
 
        [LoggerMessage(112, LogLevel.Debug, "Circuit with id {CircuitId} evicted due to {EvictionReason}.", EventName = "CircuitEvicted")]
        public static partial void CircuitEvicted(ILogger logger, CircuitId circuitId, EvictionReason evictionReason);
 
        [LoggerMessage(113, LogLevel.Debug, "Circuit with id {CircuitId} has been removed from the registry for permanent disconnection.", EventName = "CircuitDisconnectedPermanently")]
        public static partial void CircuitDisconnectedPermanently(ILogger logger, CircuitId circuitId);
 
        [LoggerMessage(114, LogLevel.Error, "Exception handler for {CircuitId} failed.", EventName = "CircuitExceptionHandlerFailed")]
        public static partial void CircuitExceptionHandlerFailed(ILogger logger, CircuitId circuitId, Exception exception);
 
        [LoggerMessage(115, LogLevel.Debug, "Reconnect to circuit with id {CircuitId} succeeded.", EventName = "ReconnectionSucceeded")]
        public static partial void ReconnectionSucceeded(ILogger logger, CircuitId circuitId);
    }
}