166 lines
6.2 KiB
C#
166 lines
6.2 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net.WebSockets;
|
|
using GameCult.Networking;
|
|
|
|
namespace GameCult.Networking.WebSockets;
|
|
|
|
/// <summary>Schema-v0 client over a binary WebSocket route.</summary>
|
|
public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICultNetSchemaClientHealth
|
|
{
|
|
private readonly ConcurrentDictionary<Type, List<Delegate>> _handlers = new();
|
|
private readonly object _handlerGate = new();
|
|
private readonly SemaphoreSlim _sendGate = new(1, 1);
|
|
private readonly CancellationTokenSource _shutdown = new();
|
|
private readonly TaskCompletionSource<Exception> _backgroundFailure = new(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
private readonly Action<ClientWebSocketOptions>? _configure;
|
|
private readonly int _maxMessageBytes;
|
|
private ClientWebSocket? _socket;
|
|
private bool _disposed;
|
|
|
|
/// <summary>Creates a binary WebSocket schema client.</summary>
|
|
public CultNetWebSocketSchemaClient(
|
|
Action<ClientWebSocketOptions>? configure = null,
|
|
int maxMessageBytes = 4 * 1024 * 1024)
|
|
{
|
|
if (maxMessageBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxMessageBytes));
|
|
_configure = configure;
|
|
_maxMessageBytes = maxMessageBytes;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool Connected => !_disposed && _socket?.State == WebSocketState.Open;
|
|
|
|
/// <inheritdoc />
|
|
public Task<Exception> BackgroundFailure => _backgroundFailure.Task;
|
|
|
|
/// <inheritdoc />
|
|
public void Connect(string host, int port)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("Host is required.", nameof(host));
|
|
if (port <= 0 || port > 65535) throw new ArgumentOutOfRangeException(nameof(port));
|
|
ConnectAsync(new Uri($"ws://{host}:{port}/cultmesh"), CancellationToken.None)
|
|
.GetAwaiter().GetResult();
|
|
}
|
|
|
|
/// <summary>Connects to an exact WebSocket route.</summary>
|
|
public async Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default)
|
|
{
|
|
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));
|
|
if (endpoint.Scheme is not ("ws" or "wss"))
|
|
throw new ArgumentException("CultNet WebSocket endpoint must use ws:// or wss://.", nameof(endpoint));
|
|
ThrowIfDisposed();
|
|
if (_socket != null) throw new InvalidOperationException("CultNet WebSocket client is already connected.");
|
|
var socket = new ClientWebSocket();
|
|
_configure?.Invoke(socket.Options);
|
|
try
|
|
{
|
|
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
|
|
_socket = socket;
|
|
_ = ReceiveLoopAsync(socket, _shutdown.Token);
|
|
}
|
|
catch
|
|
{
|
|
socket.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
|
|
{
|
|
if (message == null) throw new ArgumentNullException(nameof(message));
|
|
ThrowIfDisposed();
|
|
var socket = _socket;
|
|
if (socket?.State != WebSocketState.Open)
|
|
throw new InvalidOperationException("CultNet WebSocket client is not connected.");
|
|
var payload = CultNetSchemaMessageSerialization.Serialize(message);
|
|
if (payload.Length > _maxMessageBytes)
|
|
throw new InvalidOperationException(
|
|
$"CultNet message exceeds the {_maxMessageBytes}-byte WebSocket limit.");
|
|
_sendGate.Wait();
|
|
try
|
|
{
|
|
CultNetWebSocketMessageIO.SendAsync(socket, payload, _shutdown.Token).GetAwaiter().GetResult();
|
|
}
|
|
finally
|
|
{
|
|
_sendGate.Release();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
|
{
|
|
if (callback == null) throw new ArgumentNullException(nameof(callback));
|
|
ThrowIfDisposed();
|
|
lock (_handlerGate)
|
|
{
|
|
if (!_handlers.TryGetValue(typeof(T), out var handlers))
|
|
_handlers[typeof(T)] = handlers = new List<Delegate>();
|
|
handlers.Add(callback);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_shutdown.Cancel();
|
|
var socket = _socket;
|
|
_socket = null;
|
|
try
|
|
{
|
|
if (socket?.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
|
{
|
|
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
|
socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "CultNet client disposed", closeTimeout.Token)
|
|
.GetAwaiter().GetResult();
|
|
}
|
|
}
|
|
catch (Exception error) when (error is WebSocketException or OperationCanceledException)
|
|
{
|
|
socket?.Abort();
|
|
}
|
|
socket?.Dispose();
|
|
_sendGate.Dispose();
|
|
_shutdown.Dispose();
|
|
}
|
|
|
|
private async Task ReceiveLoopAsync(WebSocket socket, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
while (!_disposed && socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
|
{
|
|
var payload = await CultNetWebSocketMessageIO.ReceiveAsync(socket, _maxMessageBytes, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (payload == null) return;
|
|
Dispatch(CultNetSchemaMessageSerialization.Deserialize(payload));
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
}
|
|
catch (Exception error) when (!_disposed)
|
|
{
|
|
_backgroundFailure.TrySetResult(error);
|
|
}
|
|
}
|
|
|
|
private void Dispatch(ICultNetSchemaMessage message)
|
|
{
|
|
Delegate[] handlers;
|
|
lock (_handlerGate)
|
|
handlers = _handlers.TryGetValue(message.GetType(), out var registered)
|
|
? registered.ToArray()
|
|
: Array.Empty<Delegate>();
|
|
foreach (var handler in handlers) handler.DynamicInvoke(message);
|
|
}
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
if (_disposed) throw new ObjectDisposedException(nameof(CultNetWebSocketSchemaClient));
|
|
}
|
|
}
|