using CryptoExchange.Net.Interfaces; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Microsoft.Extensions.Logging; using CryptoExchange.Net.Objects; using System.Net.WebSockets; namespace CryptoExchange.Net.Sockets { /// /// A single socket connection to the server /// public class SocketConnection { /// /// Connection lost event /// public event Action? ConnectionLost; /// /// Connection closed and no reconnect is happening /// public event Action? ConnectionClosed; /// /// Connecting restored event /// public event Action? ConnectionRestored; /// /// The connection is paused event /// public event Action? ActivityPaused; /// /// The connection is unpaused event /// public event Action? ActivityUnpaused; /// /// Unhandled message event /// public event Action? UnhandledMessage; /// /// The amount of subscriptions on this connection /// public int SubscriptionCount { get { lock (_subscriptionLock) return _subscriptions.Count(h => h.UserSubscription); } } /// /// Get a copy of the current subscriptions /// public SocketSubscription[] Subscriptions { get { lock (_subscriptionLock) return _subscriptions.Where(h => h.UserSubscription).ToArray(); } } /// /// If the connection has been authenticated /// public bool Authenticated { get; internal set; } /// /// If connection is made /// public bool Connected => _socket.IsOpen; /// /// The unique ID of the socket /// public int SocketId => _socket.Id; /// /// The current kilobytes per second of data being received, averaged over the last 3 seconds /// public double IncomingKbps => _socket.IncomingKbps; /// /// The connection uri /// public Uri ConnectionUri => _socket.Uri; /// /// The API client the connection is for /// public SocketApiClient ApiClient { get; set; } /// /// Time of disconnecting /// public DateTime? DisconnectTime { get; set; } /// /// Tag for identificaion /// public string Tag { get; set; } /// /// Additional properties for this connection /// public Dictionary Properties { get; set; } /// /// If activity is paused /// public bool PausedActivity { get => _pausedActivity; set { if (_pausedActivity != value) { _pausedActivity = value; _logger.Log(LogLevel.Information, $"Socket {SocketId} Paused activity: " + value); if(_pausedActivity) _ = Task.Run(() => ActivityPaused?.Invoke()); else _ = Task.Run(() => ActivityUnpaused?.Invoke()); } } } /// /// Status of the socket connection /// public SocketStatus Status { get => _status; private set { if (_status == value) return; var oldStatus = _status; _status = value; _logger.Log(LogLevel.Debug, $"Socket {SocketId} status changed from {oldStatus} to {_status}"); } } private bool _pausedActivity; private readonly List _subscriptions; private readonly object _subscriptionLock = new(); private readonly ILogger _logger; private readonly List _pendingRequests; private SocketStatus _status; /// /// The underlying websocket /// private readonly IWebsocket _socket; /// /// New socket connection /// /// The logger /// The api client /// The socket /// public SocketConnection(ILogger logger, SocketApiClient apiClient, IWebsocket socket, string tag) { _logger = logger; ApiClient = apiClient; Tag = tag; Properties = new Dictionary(); _pendingRequests = new List(); _subscriptions = new List(); _socket = socket; _socket.OnMessage += HandleMessage; _socket.OnRequestSent += HandleRequestSent; _socket.OnOpen += HandleOpen; _socket.OnClose += HandleClose; _socket.OnReconnecting += HandleReconnecting; _socket.OnReconnected += HandleReconnected; _socket.OnError += HandleError; _socket.GetReconnectionUrl = GetReconnectionUrlAsync; } /// /// Handler for a socket opening /// protected virtual void HandleOpen() { Status = SocketStatus.Connected; PausedActivity = false; } /// /// Handler for a socket closing without reconnect /// protected virtual void HandleClose() { Status = SocketStatus.Closed; Authenticated = false; lock(_subscriptionLock) { foreach (var sub in _subscriptions) sub.Confirmed = false; } Task.Run(() => ConnectionClosed?.Invoke()); } /// /// Handler for a socket losing conenction and starting reconnect /// protected virtual void HandleReconnecting() { Status = SocketStatus.Reconnecting; DisconnectTime = DateTime.UtcNow; Authenticated = false; lock (_subscriptionLock) { foreach (var sub in _subscriptions) sub.Confirmed = false; } _ = Task.Run(() => ConnectionLost?.Invoke()); } /// /// Get the url to connect to when reconnecting /// /// protected virtual async Task GetReconnectionUrlAsync() { return await ApiClient.GetReconnectUriAsync(this).ConfigureAwait(false); } /// /// Handler for a socket which has reconnected /// protected virtual async void HandleReconnected() { Status = SocketStatus.Resubscribing; lock (_pendingRequests) { foreach (var pendingRequest in _pendingRequests.ToList()) { pendingRequest.Fail(); _pendingRequests.Remove(pendingRequest); } } var reconnectSuccessful = await ProcessReconnectAsync().ConfigureAwait(false); if (!reconnectSuccessful) { _logger.Log(LogLevel.Warning, $"Socket {SocketId} Failed reconnect processing: {reconnectSuccessful.Error}, reconnecting again"); await _socket.ReconnectAsync().ConfigureAwait(false); } else { Status = SocketStatus.Connected; _ = Task.Run(() => { ConnectionRestored?.Invoke(DateTime.UtcNow - DisconnectTime!.Value); DisconnectTime = null; }); } } /// /// Handler for an error on a websocket /// /// The exception protected virtual void HandleError(Exception e) { if (e is WebSocketException wse) _logger.Log(LogLevel.Warning, $"Socket {SocketId} error: Websocket error code {wse.WebSocketErrorCode}, details: " + e.ToLogString()); else _logger.Log(LogLevel.Warning, $"Socket {SocketId} error: " + e.ToLogString()); } /// /// Handler for whenever a request is sent over the websocket /// /// Id of the request sent protected virtual void HandleRequestSent(int requestId) { PendingRequest pendingRequest; lock (_pendingRequests) pendingRequest = _pendingRequests.SingleOrDefault(p => p.Id == requestId); if (pendingRequest == null) { _logger.Log(LogLevel.Debug, $"Socket {SocketId} - msg {requestId} - message sent, but not pending"); return; } pendingRequest.IsSend(); } /// /// Process a message received by the socket /// /// The received data protected virtual void HandleMessage(string data) { var timestamp = DateTime.UtcNow; _logger.Log(LogLevel.Trace, $"Socket {SocketId} received data: " + data); if (string.IsNullOrEmpty(data)) return; var tokenData = data.ToJToken(_logger); if (tokenData == null) { data = $"\"{data}\""; tokenData = data.ToJToken(_logger); if (tokenData == null) return; } var handledResponse = false; // Remove any timed out requests PendingRequest[] requests; lock (_pendingRequests) { // Remove only timed out requests after 5 minutes have passed so we can still process any // message coming in after the request timeout _pendingRequests.RemoveAll(r => r.Completed && DateTime.UtcNow - r.RequestTimestamp > TimeSpan.FromMinutes(5)); requests = _pendingRequests.ToArray(); } // Check if this message is an answer on any pending requests foreach (var pendingRequest in requests) { if (pendingRequest.CheckData(tokenData)) { lock (_pendingRequests) _pendingRequests.Remove(pendingRequest); if (pendingRequest.Completed) { // Answer to a timed out request, unsub if it is a subscription request if (pendingRequest.Subscription != null) { _logger.Log(LogLevel.Warning, $"Socket {SocketId} Received subscription info after request timed out; unsubscribing. Consider increasing the RequestTimeout"); _ = ApiClient.UnsubscribeAsync(this, pendingRequest.Subscription).ConfigureAwait(false); } } else { _logger.Log(LogLevel.Trace, $"Socket {SocketId} - msg {pendingRequest.Id} - received data matched to pending request"); pendingRequest.Succeed(tokenData); } if (!ApiClient.ContinueOnQueryResponse) return; handledResponse = true; break; } } // Message was not a request response, check data handlers var messageEvent = new MessageEvent(this, tokenData, ApiClient.OutputOriginalData ? data : null, timestamp); var (handled, userProcessTime, subscription) = HandleData(messageEvent); if (!handled && !handledResponse) { if (!ApiClient.UnhandledMessageExpected) _logger.Log(LogLevel.Warning, $"Socket {SocketId} Message not handled: " + tokenData); UnhandledMessage?.Invoke(tokenData); } var total = DateTime.UtcNow - timestamp; if (userProcessTime.TotalMilliseconds > 500) { _logger.Log(LogLevel.Debug, $"Socket {SocketId}{(subscription == null ? "" : " subscription " + subscription!.Id)} message processing slow ({(int)total.TotalMilliseconds}ms, {(int)userProcessTime.TotalMilliseconds}ms user code), consider offloading data handling to another thread. " + "Data from this socket may arrive late or not at all if message processing is continuously slow."); } _logger.Log(LogLevel.Trace, $"Socket {SocketId}{(subscription == null ? "" : " subscription " + subscription!.Id)} message processed in {(int)total.TotalMilliseconds}ms ({(int)userProcessTime.TotalMilliseconds}ms user code)"); } /// /// Connect the websocket /// /// public async Task ConnectAsync() => await _socket.ConnectAsync().ConfigureAwait(false); /// /// Retrieve the underlying socket /// /// public IWebsocket GetSocket() => _socket; /// /// Trigger a reconnect of the socket connection /// /// public async Task TriggerReconnectAsync() => await _socket.ReconnectAsync().ConfigureAwait(false); /// /// Close the connection /// /// public async Task CloseAsync() { if (Status == SocketStatus.Closed || Status == SocketStatus.Disposed) return; if (ApiClient.socketConnections.ContainsKey(SocketId)) ApiClient.socketConnections.TryRemove(SocketId, out _); lock (_subscriptionLock) { foreach (var subscription in _subscriptions) { if (subscription.CancellationTokenRegistration.HasValue) subscription.CancellationTokenRegistration.Value.Dispose(); } } await _socket.CloseAsync().ConfigureAwait(false); _socket.Dispose(); } /// /// Close a subscription on this connection. If all subscriptions on this connection are closed the connection gets closed as well /// /// Subscription to close /// public async Task CloseAsync(SocketSubscription subscription) { lock (_subscriptionLock) { if (!_subscriptions.Contains(subscription)) return; subscription.Closed = true; } if (Status == SocketStatus.Closing || Status == SocketStatus.Closed || Status == SocketStatus.Disposed) return; _logger.Log(LogLevel.Debug, $"Socket {SocketId} closing subscription {subscription.Id}"); if (subscription.CancellationTokenRegistration.HasValue) subscription.CancellationTokenRegistration.Value.Dispose(); if (subscription.Confirmed && _socket.IsOpen) await ApiClient.UnsubscribeAsync(this, subscription).ConfigureAwait(false); bool shouldCloseConnection; lock (_subscriptionLock) { if (Status == SocketStatus.Closing) { _logger.Log(LogLevel.Debug, $"Socket {SocketId} already closing"); return; } shouldCloseConnection = _subscriptions.All(r => !r.UserSubscription || r.Closed); if (shouldCloseConnection) Status = SocketStatus.Closing; } if (shouldCloseConnection) { _logger.Log(LogLevel.Debug, $"Socket {SocketId} closing as there are no more subscriptions"); await CloseAsync().ConfigureAwait(false); } lock (_subscriptionLock) _subscriptions.Remove(subscription); } /// /// Dispose the connection /// public void Dispose() { Status = SocketStatus.Disposed; _socket.Dispose(); } /// /// Add a subscription to this connection /// /// public bool AddSubscription(SocketSubscription subscription) { lock (_subscriptionLock) { if (Status != SocketStatus.None && Status != SocketStatus.Connected) return false; _subscriptions.Add(subscription); if(subscription.UserSubscription) _logger.Log(LogLevel.Debug, $"Socket {SocketId} adding new subscription with id {subscription.Id}, total subscriptions on connection: {_subscriptions.Count(s => s.UserSubscription)}"); return true; } } /// /// Get a subscription on this connection by id /// /// public SocketSubscription? GetSubscription(int id) { lock (_subscriptionLock) return _subscriptions.SingleOrDefault(s => s.Id == id); } /// /// Get a subscription on this connection by its subscribe request /// /// Filter for a request /// public SocketSubscription? GetSubscriptionByRequest(Func predicate) { lock(_subscriptionLock) return _subscriptions.SingleOrDefault(s => predicate(s.Request)); } /// /// Process data /// /// /// True if the data was successfully handled private (bool, TimeSpan, SocketSubscription?) HandleData(MessageEvent messageEvent) { SocketSubscription? currentSubscription = null; try { var handled = false; TimeSpan userCodeDuration = TimeSpan.Zero; // Loop the subscriptions to check if any of them signal us that the message is for them List subscriptionsCopy; lock (_subscriptionLock) subscriptionsCopy = _subscriptions.ToList(); foreach (var subscription in subscriptionsCopy) { currentSubscription = subscription; if (subscription.Request == null) { if (ApiClient.MessageMatchesHandler(this, messageEvent.JsonData, subscription.Identifier!)) { handled = true; var userSw = Stopwatch.StartNew(); subscription.MessageHandler(messageEvent); userSw.Stop(); userCodeDuration = userSw.Elapsed; } } else { if (ApiClient.MessageMatchesHandler(this, messageEvent.JsonData, subscription.Request)) { handled = true; messageEvent.JsonData = ApiClient.ProcessTokenData(messageEvent.JsonData); var userSw = Stopwatch.StartNew(); subscription.MessageHandler(messageEvent); userSw.Stop(); userCodeDuration = userSw.Elapsed; } } } return (handled, userCodeDuration, currentSubscription); } catch (Exception ex) { _logger.Log(LogLevel.Error, $"Socket {SocketId} Exception during message processing\r\nException: {ex.ToLogString()}\r\nData: {messageEvent.JsonData}"); currentSubscription?.InvokeExceptionHandler(ex); return (false, TimeSpan.Zero, null); } } /// /// Send data and wait for an answer /// /// The data type expected in response /// The object to send /// The timeout for response /// Subscription if this is a subscribe request /// The response handler, should return true if the received JToken was the response to the request /// The weight of the message /// public virtual async Task SendAndWaitAsync(T obj, TimeSpan timeout, SocketSubscription? subscription, int weight, Func handler) { var pending = new PendingRequest(ExchangeHelpers.NextId(), handler, timeout, subscription); lock (_pendingRequests) { _pendingRequests.Add(pending); } var sendOk = Send(pending.Id, obj, weight); if (!sendOk) { pending.Fail(); return; } while (true) { if(!_socket.IsOpen) { pending.Fail(); return; } if (pending.Completed) return; await pending.Event.WaitAsync(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false); if (pending.Completed) return; } } /// /// Send data over the websocket connection /// /// The type of the object to send /// The request id /// The object to send /// How null values should be serialized /// The weight of the message public virtual bool Send(int requestId, T obj, int weight, NullValueHandling nullValueHandling = NullValueHandling.Ignore) { if(obj is string str) return Send(requestId, str, weight); else return Send(requestId, JsonConvert.SerializeObject(obj, Formatting.None, new JsonSerializerSettings { NullValueHandling = nullValueHandling }), weight); } /// /// Send string data over the websocket connection /// /// The data to send /// The weight of the message /// The id of the request public virtual bool Send(int requestId, string data, int weight) { _logger.Log(LogLevel.Trace, $"Socket {SocketId} - msg {requestId} - sending messsage: {data}"); try { _socket.Send(requestId, data, weight); return true; } catch(Exception) { return false; } } private async Task> ProcessReconnectAsync() { if (!_socket.IsOpen) return new CallResult(new WebError("Socket not connected")); bool anySubscriptions = false; lock (_subscriptionLock) anySubscriptions = _subscriptions.Any(s => s.UserSubscription); if (!anySubscriptions) { // No need to resubscribe anything _logger.Log(LogLevel.Debug, $"Socket {SocketId} Nothing to resubscribe, closing connection"); _ = _socket.CloseAsync(); return new CallResult(true); } bool anyAuthenticated = false; lock (_subscriptionLock) anyAuthenticated = _subscriptions.Any(s => s.Authenticated); if (anyAuthenticated) { // If we reconnected a authenticated connection we need to re-authenticate var authResult = await ApiClient.AuthenticateSocketAsync(this).ConfigureAwait(false); if (!authResult) { _logger.Log(LogLevel.Warning, $"Socket {SocketId} authentication failed on reconnected socket. Disconnecting and reconnecting."); return authResult; } Authenticated = true; _logger.Log(LogLevel.Debug, $"Socket {SocketId} authentication succeeded on reconnected socket."); } // Get a list of all subscriptions on the socket List subscriptionList = new List(); lock (_subscriptionLock) { foreach (var subscription in _subscriptions) { if (subscription.Request != null) subscriptionList.Add(subscription); else subscription.Confirmed = true; } } foreach(var subscription in subscriptionList.Where(s => s.Request != null)) { var result = await ApiClient.RevitalizeRequestAsync(subscription.Request!).ConfigureAwait(false); if (!result) { _logger.Log(LogLevel.Warning, $"Socket {SocketId} Failed request revitalization: " + result.Error); return result.As(false); } } // Foreach subscription which is subscribed by a subscription request we will need to resend that request to resubscribe for (var i = 0; i < subscriptionList.Count; i += ApiClient.ClientOptions.MaxConcurrentResubscriptionsPerSocket) { if (!_socket.IsOpen) return new CallResult(new WebError("Socket not connected")); var taskList = new List>>(); foreach (var subscription in subscriptionList.Skip(i).Take(ApiClient.ClientOptions.MaxConcurrentResubscriptionsPerSocket)) taskList.Add(ApiClient.SubscribeAndWaitAsync(this, subscription.Request!, subscription)); await Task.WhenAll(taskList).ConfigureAwait(false); if (taskList.Any(t => !t.Result.Success)) return taskList.First(t => !t.Result.Success).Result; } foreach (var subscription in subscriptionList) subscription.Confirmed = true; if (!_socket.IsOpen) return new CallResult(new WebError("Socket not connected")); _logger.Log(LogLevel.Debug, $"Socket {SocketId} all subscription successfully resubscribed on reconnected socket."); return new CallResult(true); } internal async Task UnsubscribeAsync(SocketSubscription socketSubscription) { await ApiClient.UnsubscribeAsync(this, socketSubscription).ConfigureAwait(false); } internal async Task> ResubscribeAsync(SocketSubscription socketSubscription) { if (!_socket.IsOpen) return new CallResult(new UnknownError("Socket is not connected")); return await ApiClient.SubscribeAndWaitAsync(this, socketSubscription.Request!, socketSubscription).ConfigureAwait(false); } /// /// Status of the socket connection /// public enum SocketStatus { /// /// None/Initial /// None, /// /// Connected /// Connected, /// /// Reconnecting /// Reconnecting, /// /// Resubscribing on reconnected socket /// Resubscribing, /// /// Closing /// Closing, /// /// Closed /// Closed, /// /// Disposed /// Disposed } } }