| File: Internal\HttpConnectionManager.cs | Web Access |
| Project: src\aspnetcore\src\SignalR\common\Http.Connections\src\Microsoft.AspNetCore.Http.Connections.csproj (Microsoft.AspNetCore.Http.Connections) |
// 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 System.Diagnostics.CodeAnalysis; using System.Net.WebSockets; using System.Security.Cryptography; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using static System.IO.Pipelines.DuplexPipe; namespace Microsoft.AspNetCore.Http.Connections.Internal; internal sealed partial class HttpConnectionManager { // TODO: Consider making this configurable? At least for testing? private static readonly TimeSpan _heartbeatTickRate = TimeSpan.FromSeconds(1); private readonly ConcurrentDictionary<string, HttpConnectionContext> _connections = new ConcurrentDictionary<string, HttpConnectionContext>(StringComparer.Ordinal); private readonly PeriodicTimer _nextHeartbeat; private readonly ILogger<HttpConnectionManager> _logger; private readonly ILogger<HttpConnectionContext> _connectionLogger; private readonly TimeSpan _disconnectTimeout; private readonly HttpConnectionsMetrics _metrics; public HttpConnectionManager(ILoggerFactory loggerFactory, IHostApplicationLifetime appLifetime, IOptions<ConnectionOptions> connectionOptions, HttpConnectionsMetrics metrics) { _logger = loggerFactory.CreateLogger<HttpConnectionManager>(); _connectionLogger = loggerFactory.CreateLogger<HttpConnectionContext>(); _nextHeartbeat = new PeriodicTimer(_heartbeatTickRate); _disconnectTimeout = connectionOptions.Value.DisconnectTimeout ?? ConnectionOptionsSetup.DefaultDisconectTimeout; _metrics = metrics; // Register these last as the callbacks could run immediately appLifetime.ApplicationStarted.Register(Start); appLifetime.ApplicationStopping.Register(CloseConnections); } public void Start() { // Start the timer loop _ = ExecuteTimerLoop(); } internal bool TryGetConnection(string id, [NotNullWhen(true)] out HttpConnectionContext? connection) { return _connections.TryGetValue(id, out connection); } internal HttpConnectionContext CreateConnection() { return CreateConnection(new()); } /// <summary> /// Creates a connection without Pipes setup to allow saving allocations until Pipes are needed. /// </summary> /// <returns></returns> internal HttpConnectionContext CreateConnection(HttpConnectionDispatcherOptions options, int negotiateVersion = 0, bool useStatefulReconnect = false) { string connectionToken; var id = MakeNewConnectionId(); if (negotiateVersion > 0) { connectionToken = MakeNewConnectionId(); } else { connectionToken = id; } var metricsContext = _metrics.CreateContext(); Log.CreatedNewConnection(_logger, id); var pair = CreateConnectionPair(options.TransportPipeOptions, options.AppPipeOptions); var connection = new HttpConnectionContext(id, connectionToken, _connectionLogger, metricsContext, pair.Application, pair.Transport, options, useStatefulReconnect); _connections.TryAdd(connectionToken, connection); return connection; } public void RemoveConnection(string id, HttpTransportType transportType, HttpConnectionStopStatus status) { // Remove the connection completely if (_connections.TryRemove(id, out var connection)) { // A connection is considered started when the transport is negotiated. // You can't stop something that hasn't started so only log connection stop events if there is a transport. if (connection.TransportType != HttpTransportType.None) { var currentTimestamp = (connection.StartTimestamp > 0) ? Stopwatch.GetTimestamp() : default; HttpConnectionsEventSource.Log.ConnectionStop(id, connection.StartTimestamp, currentTimestamp); _metrics.TransportStop(connection.MetricsContext, transportType); _metrics.ConnectionStop(connection.MetricsContext, transportType, status, connection.StartTimestamp, currentTimestamp); } Log.RemovedConnection(_logger, id); } } private static string MakeNewConnectionId() { // 128 bit buffer / 8 bits per byte = 16 bytes Span<byte> buffer = stackalloc byte[16]; // Generate the id with RNGCrypto because we want a cryptographically random id, which GUID is not RandomNumberGenerator.Fill(buffer); return WebEncoders.Base64UrlEncode(buffer); } private async Task ExecuteTimerLoop() { Log.HeartBeatStarted(_logger); // Dispose the timer when all the code consuming callbacks has completed using (_nextHeartbeat) { // The TimerAwaitable will return true until Stop is called while (await _nextHeartbeat.WaitForNextTickAsync()) { try { Scan(); } catch (Exception ex) { Log.ScanningConnectionsFailed(_logger, ex); } } } Log.HeartBeatEnded(_logger); } public void Scan() { var now = DateTimeOffset.UtcNow; var ticks = TimeSpan.FromMilliseconds(Environment.TickCount64); // Scan the registered connections looking for ones that have timed out foreach (var c in _connections) { var connection = c.Value; // Capture the connection state var lastSeenTick = connection.LastSeenTicksIfInactive; // Once the decision has been made to dispose we don't check the status again // But don't clean up connections while the debugger is attached. if (!Debugger.IsAttached && lastSeenTick.HasValue && (ticks - lastSeenTick.Value) > _disconnectTimeout) { Log.ConnectionTimedOut(_logger, connection.ConnectionId); HttpConnectionsEventSource.Log.ConnectionTimedOut(connection.ConnectionId); // This is most likely a long polling connection. The transport here ends because // a poll completed and has been inactive for > 5 seconds so we wait for the // application to finish gracefully _ = DisposeAndRemoveAsync(connection, closeGracefully: true, HttpConnectionStopStatus.Timeout); } else { if (!Debugger.IsAttached) { connection.TryCancelSend(ticks); } // Tick the heartbeat, if the connection is still active connection.TickHeartbeat(); if (connection.IsAuthenticationExpirationEnabled && connection.AuthenticationExpiration < now && !connection.ConnectionClosedRequested.IsCancellationRequested) { Log.AuthenticationExpired(_logger, connection.ConnectionId); connection.RequestClose(); } } } } public void CloseConnections() { // Stop firing the timer _nextHeartbeat.Dispose(); var tasks = new List<Task>(_connections.Count); // REVIEW: In the future we can consider a hybrid where we first try to wait for shutdown // for a certain time frame then after some grace period we shutdown more aggressively foreach (var c in _connections) { // We're shutting down so don't wait for closing the application tasks.Add(DisposeAndRemoveAsync(c.Value, closeGracefully: false, HttpConnectionStopStatus.AppShutdown)); } Task.WaitAll(tasks.ToArray(), TimeSpan.FromSeconds(5)); } internal async Task DisposeAndRemoveAsync(HttpConnectionContext connection, bool closeGracefully, HttpConnectionStopStatus status) { try { await connection.DisposeAsync(closeGracefully); } catch (IOException ex) { Log.ConnectionReset(_logger, connection.ConnectionId, ex); } catch (WebSocketException ex) when (ex.InnerException is IOException) { Log.ConnectionReset(_logger, connection.ConnectionId, ex); } catch (Exception ex) { Log.FailedDispose(_logger, connection.ConnectionId, ex); } finally { // Remove it from the list after disposal so that's it's easy to see // connections that might be in a hung state via the connections list RemoveConnection(connection.ConnectionToken, connection.TransportType, status); } } }