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