Add unit tests for Router configuration and transport layers
- Implemented tests for RouterConfig, RoutingOptions, StaticInstanceConfig, and RouterConfigOptions to ensure default values are set correctly. - Added tests for RouterConfigProvider to validate configurations and ensure defaults are returned when no file is specified. - Created tests for ConfigValidationResult to check success and error scenarios. - Developed tests for ServiceCollectionExtensions to verify service registration for RouterConfig. - Introduced UdpTransportTests to validate serialization, connection, request-response, and error handling in UDP transport. - Added scripts for signing authority gaps and hashing DevPortal SDK snippets.
This commit is contained in:
144
src/__Libraries/StellaOps.Router.Transport.Tcp/FrameProtocol.cs
Normal file
144
src/__Libraries/StellaOps.Router.Transport.Tcp/FrameProtocol.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.Buffers.Binary;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.Tcp;
|
||||
|
||||
/// <summary>
|
||||
/// Handles reading and writing length-prefixed frames over a stream.
|
||||
/// Frame format: [4-byte big-endian length][payload]
|
||||
/// Payload format: [1-byte frame type][16-byte correlation GUID][remaining data]
|
||||
/// </summary>
|
||||
public static class FrameProtocol
|
||||
{
|
||||
private const int LengthPrefixSize = 4;
|
||||
private const int FrameTypeSize = 1;
|
||||
private const int CorrelationIdSize = 16;
|
||||
private const int HeaderSize = FrameTypeSize + CorrelationIdSize;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a complete frame from the stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream to read from.</param>
|
||||
/// <param name="maxFrameSize">The maximum frame size allowed.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The frame read, or null if the stream is closed.</returns>
|
||||
public static async Task<Frame?> ReadFrameAsync(
|
||||
Stream stream,
|
||||
int maxFrameSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Read length prefix (4 bytes, big-endian)
|
||||
var lengthBuffer = new byte[LengthPrefixSize];
|
||||
var bytesRead = await ReadExactAsync(stream, lengthBuffer, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
return null; // Connection closed
|
||||
}
|
||||
|
||||
if (bytesRead < LengthPrefixSize)
|
||||
{
|
||||
throw new InvalidOperationException("Incomplete length prefix received");
|
||||
}
|
||||
|
||||
var payloadLength = BinaryPrimitives.ReadInt32BigEndian(lengthBuffer);
|
||||
if (payloadLength < HeaderSize)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid payload length: {payloadLength}");
|
||||
}
|
||||
|
||||
if (payloadLength > maxFrameSize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Frame size {payloadLength} exceeds maximum {maxFrameSize}");
|
||||
}
|
||||
|
||||
// Read payload
|
||||
var payload = new byte[payloadLength];
|
||||
bytesRead = await ReadExactAsync(stream, payload, cancellationToken);
|
||||
if (bytesRead < payloadLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Incomplete payload: expected {payloadLength}, got {bytesRead}");
|
||||
}
|
||||
|
||||
// Parse frame
|
||||
var frameType = (FrameType)payload[0];
|
||||
var correlationId = new Guid(payload.AsSpan(FrameTypeSize, CorrelationIdSize));
|
||||
var data = payload.AsMemory(HeaderSize);
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Type = frameType,
|
||||
CorrelationId = correlationId.ToString("N"),
|
||||
Payload = data
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a frame to the stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream to write to.</param>
|
||||
/// <param name="frame">The frame to write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public static async Task WriteFrameAsync(
|
||||
Stream stream,
|
||||
Frame frame,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Parse or generate correlation ID
|
||||
var correlationGuid = frame.CorrelationId is not null &&
|
||||
Guid.TryParse(frame.CorrelationId, out var parsed)
|
||||
? parsed
|
||||
: Guid.NewGuid();
|
||||
|
||||
var dataLength = frame.Payload.Length;
|
||||
var payloadLength = HeaderSize + dataLength;
|
||||
|
||||
// Create buffer for the complete message
|
||||
var buffer = new byte[LengthPrefixSize + payloadLength];
|
||||
|
||||
// Write length prefix (big-endian)
|
||||
BinaryPrimitives.WriteInt32BigEndian(buffer.AsSpan(0, LengthPrefixSize), payloadLength);
|
||||
|
||||
// Write frame type
|
||||
buffer[LengthPrefixSize] = (byte)frame.Type;
|
||||
|
||||
// Write correlation ID
|
||||
correlationGuid.TryWriteBytes(buffer.AsSpan(LengthPrefixSize + FrameTypeSize, CorrelationIdSize));
|
||||
|
||||
// Write data
|
||||
if (dataLength > 0)
|
||||
{
|
||||
frame.Payload.Span.CopyTo(buffer.AsSpan(LengthPrefixSize + HeaderSize));
|
||||
}
|
||||
|
||||
await stream.WriteAsync(buffer, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads exactly the specified number of bytes from the stream.
|
||||
/// </summary>
|
||||
private static async Task<int> ReadExactAsync(
|
||||
Stream stream,
|
||||
Memory<byte> buffer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var totalRead = 0;
|
||||
while (totalRead < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(
|
||||
buffer[totalRead..],
|
||||
cancellationToken);
|
||||
|
||||
if (read == 0)
|
||||
{
|
||||
return totalRead; // EOF
|
||||
}
|
||||
|
||||
totalRead += read;
|
||||
}
|
||||
|
||||
return totalRead;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.Tcp;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks pending requests waiting for responses.
|
||||
/// Enables multiplexing multiple concurrent requests on a single connection.
|
||||
/// </summary>
|
||||
public sealed class PendingRequestTracker : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, TaskCompletionSource<Frame>> _pending = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a request and returns a task that completes when the response arrives.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID of the request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that completes with the response frame.</returns>
|
||||
public Task<Frame> TrackRequest(Guid correlationId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var tcs = new TaskCompletionSource<Frame>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// Register cancellation callback
|
||||
var registration = cancellationToken.Register(() =>
|
||||
{
|
||||
if (_pending.TryRemove(correlationId, out var pendingTcs))
|
||||
{
|
||||
pendingTcs.TrySetCanceled(cancellationToken);
|
||||
}
|
||||
});
|
||||
|
||||
// Store registration in state to dispose when completed
|
||||
tcs.Task.ContinueWith(_ => registration.Dispose(), TaskScheduler.Default);
|
||||
|
||||
_pending[correlationId] = tcs;
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes a pending request with the response.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID.</param>
|
||||
/// <param name="response">The response frame.</param>
|
||||
/// <returns>True if the request was found and completed; false otherwise.</returns>
|
||||
public bool CompleteRequest(Guid correlationId, Frame response)
|
||||
{
|
||||
if (_pending.TryRemove(correlationId, out var tcs))
|
||||
{
|
||||
return tcs.TrySetResult(response);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails a pending request with an exception.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID.</param>
|
||||
/// <param name="exception">The exception.</param>
|
||||
/// <returns>True if the request was found and failed; false otherwise.</returns>
|
||||
public bool FailRequest(Guid correlationId, Exception exception)
|
||||
{
|
||||
if (_pending.TryRemove(correlationId, out var tcs))
|
||||
{
|
||||
return tcs.TrySetException(exception);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending request.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID.</param>
|
||||
/// <returns>True if the request was found and cancelled; false otherwise.</returns>
|
||||
public bool CancelRequest(Guid correlationId)
|
||||
{
|
||||
if (_pending.TryRemove(correlationId, out var tcs))
|
||||
{
|
||||
return tcs.TrySetCanceled();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of pending requests.
|
||||
/// </summary>
|
||||
public int Count => _pending.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Cancels all pending requests.
|
||||
/// </summary>
|
||||
/// <param name="exception">Optional exception to set.</param>
|
||||
public void CancelAll(Exception? exception = null)
|
||||
{
|
||||
foreach (var kvp in _pending)
|
||||
{
|
||||
if (_pending.TryRemove(kvp.Key, out var tcs))
|
||||
{
|
||||
if (exception is not null)
|
||||
{
|
||||
tcs.TrySetException(exception);
|
||||
}
|
||||
else
|
||||
{
|
||||
tcs.TrySetCanceled();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
CancelAll(new ObjectDisposedException(nameof(PendingRequestTracker)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
|
||||
namespace StellaOps.Router.Transport.Tcp;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering TCP transport services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds TCP transport server services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddTcpTransportServer(
|
||||
this IServiceCollection services,
|
||||
Action<TcpTransportOptions>? configure = null)
|
||||
{
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddSingleton<TcpTransportServer>();
|
||||
services.AddSingleton<ITransportServer>(sp => sp.GetRequiredService<TcpTransportServer>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds TCP transport client services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddTcpTransportClient(
|
||||
this IServiceCollection services,
|
||||
Action<TcpTransportOptions>? configure = null)
|
||||
{
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddSingleton<TcpTransportClient>();
|
||||
services.AddSingleton<ITransportClient>(sp => sp.GetRequiredService<TcpTransportClient>());
|
||||
services.AddSingleton<IMicroserviceTransport>(sp => sp.GetRequiredService<TcpTransportClient>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Router.Transport.Tcp</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
182
src/__Libraries/StellaOps.Router.Transport.Tcp/TcpConnection.cs
Normal file
182
src/__Libraries/StellaOps.Router.Transport.Tcp/TcpConnection.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.Tcp;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a TCP connection to a microservice.
|
||||
/// </summary>
|
||||
public sealed class TcpConnection : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpClient _client;
|
||||
private readonly NetworkStream _stream;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly TcpTransportOptions _options;
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _connectionCts = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection ID.
|
||||
/// </summary>
|
||||
public string ConnectionId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remote endpoint as a string.
|
||||
/// </summary>
|
||||
public string RemoteEndpoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the connection is active.
|
||||
/// </summary>
|
||||
public bool IsConnected => _client.Connected && !_disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection state.
|
||||
/// </summary>
|
||||
public ConnectionState? State { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cancellation token for this connection.
|
||||
/// </summary>
|
||||
public CancellationToken ConnectionToken => _connectionCts.Token;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a frame is received.
|
||||
/// </summary>
|
||||
public event Action<TcpConnection, Frame>? OnFrameReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the connection is closed.
|
||||
/// </summary>
|
||||
public event Action<TcpConnection, Exception?>? OnDisconnected;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TcpConnection"/> class.
|
||||
/// </summary>
|
||||
public TcpConnection(
|
||||
string connectionId,
|
||||
TcpClient client,
|
||||
TcpTransportOptions options,
|
||||
ILogger logger)
|
||||
{
|
||||
ConnectionId = connectionId;
|
||||
_client = client;
|
||||
_stream = client.GetStream();
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
RemoteEndpoint = client.Client.RemoteEndPoint?.ToString() ?? "unknown";
|
||||
|
||||
// Configure socket options
|
||||
client.ReceiveBufferSize = options.ReceiveBufferSize;
|
||||
client.SendBufferSize = options.SendBufferSize;
|
||||
client.NoDelay = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the read loop to receive frames.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task ReadLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken, _connectionCts.Token);
|
||||
|
||||
Exception? disconnectException = null;
|
||||
|
||||
try
|
||||
{
|
||||
while (!linkedCts.Token.IsCancellationRequested)
|
||||
{
|
||||
var frame = await FrameProtocol.ReadFrameAsync(
|
||||
_stream,
|
||||
_options.MaxFrameSize,
|
||||
linkedCts.Token);
|
||||
|
||||
if (frame is null)
|
||||
{
|
||||
_logger.LogDebug("Connection {ConnectionId} closed by remote", ConnectionId);
|
||||
break;
|
||||
}
|
||||
|
||||
OnFrameReceived?.Invoke(this, frame);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on shutdown
|
||||
}
|
||||
catch (IOException ex) when (ex.InnerException is SocketException)
|
||||
{
|
||||
disconnectException = ex;
|
||||
_logger.LogDebug(ex, "Connection {ConnectionId} socket error", ConnectionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
disconnectException = ex;
|
||||
_logger.LogWarning(ex, "Connection {ConnectionId} read error", ConnectionId);
|
||||
}
|
||||
|
||||
OnDisconnected?.Invoke(this, disconnectException);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a frame to the connection.
|
||||
/// </summary>
|
||||
/// <param name="frame">The frame to write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task WriteFrameAsync(Frame frame, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await FrameProtocol.WriteFrameAsync(_stream, frame, cancellationToken);
|
||||
await _stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the connection.
|
||||
/// </summary>
|
||||
public void Close()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
try
|
||||
{
|
||||
_connectionCts.Cancel();
|
||||
_client.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error closing connection {ConnectionId}", ConnectionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
await _connectionCts.CancelAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
|
||||
_client.Dispose();
|
||||
_writeLock.Dispose();
|
||||
_connectionCts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.Tcp;
|
||||
|
||||
/// <summary>
|
||||
/// TCP transport client implementation for microservices.
|
||||
/// </summary>
|
||||
public sealed class TcpTransportClient : ITransportClient, IMicroserviceTransport, IAsyncDisposable
|
||||
{
|
||||
private readonly TcpTransportOptions _options;
|
||||
private readonly ILogger<TcpTransportClient> _logger;
|
||||
private readonly PendingRequestTracker _pendingRequests = new();
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _inflightHandlers = new();
|
||||
private readonly CancellationTokenSource _clientCts = new();
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private Task? _receiveTask;
|
||||
private bool _disposed;
|
||||
private string? _connectionId;
|
||||
private int _reconnectAttempts;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a REQUEST frame is received.
|
||||
/// </summary>
|
||||
public event Func<Frame, CancellationToken, Task<Frame>>? OnRequestReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a CANCEL frame is received.
|
||||
/// </summary>
|
||||
public event Func<Guid, string?, Task>? OnCancelReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TcpTransportClient"/> class.
|
||||
/// </summary>
|
||||
public TcpTransportClient(
|
||||
IOptions<TcpTransportOptions> options,
|
||||
ILogger<TcpTransportClient> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the gateway.
|
||||
/// </summary>
|
||||
/// <param name="instance">The instance descriptor.</param>
|
||||
/// <param name="endpoints">The endpoints to register.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task ConnectAsync(
|
||||
InstanceDescriptor instance,
|
||||
IReadOnlyList<EndpointDescriptor> endpoints,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (string.IsNullOrEmpty(_options.Host))
|
||||
{
|
||||
throw new InvalidOperationException("Host is not configured");
|
||||
}
|
||||
|
||||
await ConnectInternalAsync(cancellationToken);
|
||||
|
||||
_connectionId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Send HELLO frame
|
||||
var helloFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
await WriteFrameAsync(helloFrame, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Connected to TCP gateway at {Host}:{Port} as {ServiceName}/{Version}",
|
||||
_options.Host,
|
||||
_options.Port,
|
||||
instance.ServiceName,
|
||||
instance.Version);
|
||||
|
||||
// Start receiving frames
|
||||
_receiveTask = Task.Run(() => ReceiveLoopAsync(_clientCts.Token), CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task ConnectInternalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(_options.ConnectTimeout);
|
||||
|
||||
_client = new TcpClient
|
||||
{
|
||||
ReceiveBufferSize = _options.ReceiveBufferSize,
|
||||
SendBufferSize = _options.SendBufferSize,
|
||||
NoDelay = true
|
||||
};
|
||||
|
||||
await _client.ConnectAsync(_options.Host!, _options.Port, timeoutCts.Token);
|
||||
_stream = _client.GetStream();
|
||||
_reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
private async Task ReconnectAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
while (_reconnectAttempts < _options.MaxReconnectAttempts && !_clientCts.Token.IsCancellationRequested)
|
||||
{
|
||||
_reconnectAttempts++;
|
||||
var backoff = TimeSpan.FromMilliseconds(
|
||||
Math.Min(
|
||||
Math.Pow(2, _reconnectAttempts) * 100,
|
||||
_options.MaxReconnectBackoff.TotalMilliseconds));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Reconnection attempt {Attempt} of {Max} in {Delay}ms",
|
||||
_reconnectAttempts,
|
||||
_options.MaxReconnectAttempts,
|
||||
backoff.TotalMilliseconds);
|
||||
|
||||
await Task.Delay(backoff, _clientCts.Token);
|
||||
|
||||
try
|
||||
{
|
||||
_client?.Dispose();
|
||||
await ConnectInternalAsync(_clientCts.Token);
|
||||
_logger.LogInformation("Reconnected to gateway");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reconnection attempt {Attempt} failed", _reconnectAttempts);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogError("Max reconnection attempts reached, giving up");
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frame = await FrameProtocol.ReadFrameAsync(
|
||||
_stream!,
|
||||
_options.MaxFrameSize,
|
||||
cancellationToken);
|
||||
|
||||
if (frame is null)
|
||||
{
|
||||
_logger.LogDebug("Connection closed by server");
|
||||
await ReconnectAsync();
|
||||
continue;
|
||||
}
|
||||
|
||||
await ProcessFrameAsync(frame, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (IOException ex) when (ex.InnerException is SocketException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Socket error, attempting reconnection");
|
||||
await ReconnectAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in receive loop");
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessFrameAsync(Frame frame, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (frame.Type)
|
||||
{
|
||||
case FrameType.Request:
|
||||
case FrameType.RequestStreamData:
|
||||
await HandleRequestFrameAsync(frame, cancellationToken);
|
||||
break;
|
||||
|
||||
case FrameType.Cancel:
|
||||
HandleCancelFrame(frame);
|
||||
break;
|
||||
|
||||
case FrameType.Response:
|
||||
case FrameType.ResponseStreamData:
|
||||
if (frame.CorrelationId is not null &&
|
||||
Guid.TryParse(frame.CorrelationId, out var correlationId))
|
||||
{
|
||||
_pendingRequests.CompleteRequest(correlationId, frame);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Unexpected frame type {FrameType}", frame.Type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestFrameAsync(Frame frame, CancellationToken cancellationToken)
|
||||
{
|
||||
if (OnRequestReceived is null)
|
||||
{
|
||||
_logger.LogWarning("No request handler registered");
|
||||
return;
|
||||
}
|
||||
|
||||
var correlationId = frame.CorrelationId ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
using var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_inflightHandlers[correlationId] = handlerCts;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await OnRequestReceived(frame, handlerCts.Token);
|
||||
var responseFrame = response with { CorrelationId = correlationId };
|
||||
|
||||
if (!handlerCts.Token.IsCancellationRequested)
|
||||
{
|
||||
await WriteFrameAsync(responseFrame, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Request {CorrelationId} was cancelled", correlationId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling request {CorrelationId}", correlationId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inflightHandlers.TryRemove(correlationId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleCancelFrame(Frame frame)
|
||||
{
|
||||
if (frame.CorrelationId is null) return;
|
||||
|
||||
_logger.LogDebug("Received CANCEL for {CorrelationId}", frame.CorrelationId);
|
||||
|
||||
if (_inflightHandlers.TryGetValue(frame.CorrelationId, out var cts))
|
||||
{
|
||||
try
|
||||
{
|
||||
cts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already completed
|
||||
}
|
||||
}
|
||||
|
||||
if (Guid.TryParse(frame.CorrelationId, out var guid))
|
||||
{
|
||||
_pendingRequests.CancelRequest(guid);
|
||||
OnCancelReceived?.Invoke(guid, null);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteFrameAsync(Frame frame, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await FrameProtocol.WriteFrameAsync(_stream!, frame, cancellationToken);
|
||||
await _stream!.FlushAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Frame> SendRequestAsync(
|
||||
ConnectionState connection,
|
||||
Frame requestFrame,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var correlationId = requestFrame.CorrelationId is not null &&
|
||||
Guid.TryParse(requestFrame.CorrelationId, out var parsed)
|
||||
? parsed
|
||||
: Guid.NewGuid();
|
||||
|
||||
var framedRequest = requestFrame with { CorrelationId = correlationId.ToString("N") };
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
var responseTask = _pendingRequests.TrackRequest(correlationId, timeoutCts.Token);
|
||||
|
||||
await WriteFrameAsync(framedRequest, timeoutCts.Token);
|
||||
|
||||
try
|
||||
{
|
||||
return await responseTask;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"Request {correlationId} timed out after {timeout}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendCancelAsync(
|
||||
ConnectionState connection,
|
||||
Guid correlationId,
|
||||
string? reason = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var cancelFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Cancel,
|
||||
CorrelationId = correlationId.ToString("N"),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
|
||||
await WriteFrameAsync(cancelFrame, CancellationToken.None);
|
||||
_logger.LogDebug("Sent CANCEL for {CorrelationId}", correlationId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendStreamingAsync(
|
||||
ConnectionState connection,
|
||||
Frame requestHeader,
|
||||
Stream requestBody,
|
||||
Func<Stream, Task> readResponseBody,
|
||||
PayloadLimits limits,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var correlationId = requestHeader.CorrelationId is not null &&
|
||||
Guid.TryParse(requestHeader.CorrelationId, out var parsed)
|
||||
? parsed
|
||||
: Guid.NewGuid();
|
||||
|
||||
var headerFrame = requestHeader with
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = correlationId.ToString("N")
|
||||
};
|
||||
await WriteFrameAsync(headerFrame, cancellationToken);
|
||||
|
||||
// Stream request body
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(8192);
|
||||
try
|
||||
{
|
||||
long totalBytesRead = 0;
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await requestBody.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
totalBytesRead += bytesRead;
|
||||
|
||||
if (totalBytesRead > limits.MaxRequestBytesPerCall)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Request body exceeds limit of {limits.MaxRequestBytesPerCall} bytes");
|
||||
}
|
||||
|
||||
var dataFrame = new Frame
|
||||
{
|
||||
Type = FrameType.RequestStreamData,
|
||||
CorrelationId = correlationId.ToString("N"),
|
||||
Payload = new ReadOnlyMemory<byte>(buffer, 0, bytesRead)
|
||||
};
|
||||
await WriteFrameAsync(dataFrame, cancellationToken);
|
||||
}
|
||||
|
||||
// End of stream marker
|
||||
var endFrame = new Frame
|
||||
{
|
||||
Type = FrameType.RequestStreamData,
|
||||
CorrelationId = correlationId.ToString("N"),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
await WriteFrameAsync(endFrame, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
// Read streaming response
|
||||
using var responseStream = new MemoryStream();
|
||||
await readResponseBody(responseStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a heartbeat.
|
||||
/// </summary>
|
||||
public async Task SendHeartbeatAsync(HeartbeatPayload heartbeat, CancellationToken cancellationToken)
|
||||
{
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat,
|
||||
CorrelationId = null,
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
|
||||
await WriteFrameAsync(frame, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels all in-flight handlers.
|
||||
/// </summary>
|
||||
public void CancelAllInflight(string reason)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var cts in _inflightHandlers.Values)
|
||||
{
|
||||
try
|
||||
{
|
||||
cts.Cancel();
|
||||
count++;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already completed
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
_logger.LogInformation("Cancelled {Count} in-flight handlers: {Reason}", count, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects from the gateway.
|
||||
/// </summary>
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
CancelAllInflight("Shutdown");
|
||||
|
||||
await _clientCts.CancelAsync();
|
||||
|
||||
if (_receiveTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _receiveTask;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
_client?.Dispose();
|
||||
_logger.LogInformation("Disconnected from TCP gateway");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
await DisconnectAsync();
|
||||
|
||||
_pendingRequests.Dispose();
|
||||
_writeLock.Dispose();
|
||||
_clientCts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Router.Transport.Tcp;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for TCP transport.
|
||||
/// </summary>
|
||||
public sealed class TcpTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the address to bind to.
|
||||
/// Default: IPAddress.Any (0.0.0.0).
|
||||
/// </summary>
|
||||
public IPAddress BindAddress { get; set; } = IPAddress.Any;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the port to listen on.
|
||||
/// Default: 5100.
|
||||
/// </summary>
|
||||
public int Port { get; set; } = 5100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the receive buffer size.
|
||||
/// Default: 64 KB.
|
||||
/// </summary>
|
||||
public int ReceiveBufferSize { get; set; } = 64 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the send buffer size.
|
||||
/// Default: 64 KB.
|
||||
/// </summary>
|
||||
public int SendBufferSize { get; set; } = 64 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the keep-alive interval.
|
||||
/// Default: 30 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan KeepAliveInterval { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the connection timeout.
|
||||
/// Default: 10 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of reconnection attempts.
|
||||
/// Default: 10.
|
||||
/// </summary>
|
||||
public int MaxReconnectAttempts { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum reconnection backoff.
|
||||
/// Default: 1 minute.
|
||||
/// </summary>
|
||||
public TimeSpan MaxReconnectBackoff { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum frame size in bytes.
|
||||
/// Default: 16 MB.
|
||||
/// </summary>
|
||||
public int MaxFrameSize { get; set; } = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the host for client connections.
|
||||
/// </summary>
|
||||
public string? Host { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.Tcp;
|
||||
|
||||
/// <summary>
|
||||
/// TCP transport server implementation for the gateway.
|
||||
/// </summary>
|
||||
public sealed class TcpTransportServer : ITransportServer, IAsyncDisposable
|
||||
{
|
||||
private readonly TcpTransportOptions _options;
|
||||
private readonly ILogger<TcpTransportServer> _logger;
|
||||
private readonly ConcurrentDictionary<string, TcpConnection> _connections = new();
|
||||
private TcpListener? _listener;
|
||||
private CancellationTokenSource? _serverCts;
|
||||
private Task? _acceptTask;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a connection is established.
|
||||
/// </summary>
|
||||
public event Action<string, ConnectionState>? OnConnection;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a connection is lost.
|
||||
/// </summary>
|
||||
public event Action<string>? OnDisconnection;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a frame is received.
|
||||
/// </summary>
|
||||
public event Action<string, Frame>? OnFrame;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TcpTransportServer"/> class.
|
||||
/// </summary>
|
||||
public TcpTransportServer(
|
||||
IOptions<TcpTransportOptions> options,
|
||||
ILogger<TcpTransportServer> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_serverCts = new CancellationTokenSource();
|
||||
_listener = new TcpListener(_options.BindAddress, _options.Port);
|
||||
_listener.Start();
|
||||
|
||||
_logger.LogInformation(
|
||||
"TCP transport server listening on {Address}:{Port}",
|
||||
_options.BindAddress,
|
||||
_options.Port);
|
||||
|
||||
_acceptTask = AcceptLoopAsync(_serverCts.Token);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await _listener!.AcceptTcpClientAsync(cancellationToken);
|
||||
var connectionId = GenerateConnectionId(client);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Accepted connection {ConnectionId} from {RemoteEndpoint}",
|
||||
connectionId,
|
||||
client.Client.RemoteEndPoint);
|
||||
|
||||
var connection = new TcpConnection(connectionId, client, _options, _logger);
|
||||
_connections[connectionId] = connection;
|
||||
|
||||
connection.OnFrameReceived += HandleFrame;
|
||||
connection.OnDisconnected += HandleDisconnect;
|
||||
|
||||
// Start read loop (non-blocking)
|
||||
_ = Task.Run(() => connection.ReadLoopAsync(cancellationToken), CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on shutdown
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Listener disposed
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error accepting connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleFrame(TcpConnection connection, Frame frame)
|
||||
{
|
||||
// If this is a HELLO frame, create the ConnectionState
|
||||
if (frame.Type == FrameType.Hello && connection.State is null)
|
||||
{
|
||||
var state = new ConnectionState
|
||||
{
|
||||
ConnectionId = connection.ConnectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = connection.ConnectionId,
|
||||
ServiceName = "unknown", // Will be updated from HELLO payload
|
||||
Version = "1.0.0",
|
||||
Region = "default"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = TransportType.Tcp
|
||||
};
|
||||
connection.State = state;
|
||||
OnConnection?.Invoke(connection.ConnectionId, state);
|
||||
}
|
||||
|
||||
OnFrame?.Invoke(connection.ConnectionId, frame);
|
||||
}
|
||||
|
||||
private void HandleDisconnect(TcpConnection connection, Exception? ex)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Connection {ConnectionId} disconnected{Reason}",
|
||||
connection.ConnectionId,
|
||||
ex is not null ? $": {ex.Message}" : string.Empty);
|
||||
|
||||
_connections.TryRemove(connection.ConnectionId, out _);
|
||||
OnDisconnection?.Invoke(connection.ConnectionId);
|
||||
|
||||
// Clean up connection
|
||||
_ = connection.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a frame to a connection.
|
||||
/// </summary>
|
||||
/// <param name="connectionId">The connection ID.</param>
|
||||
/// <param name="frame">The frame to send.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task SendFrameAsync(
|
||||
string connectionId,
|
||||
Frame frame,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_connections.TryGetValue(connectionId, out var connection))
|
||||
{
|
||||
await connection.WriteFrameAsync(frame, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"Connection {connectionId} not found");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a connection by ID.
|
||||
/// </summary>
|
||||
/// <param name="connectionId">The connection ID.</param>
|
||||
/// <returns>The connection, or null if not found.</returns>
|
||||
public TcpConnection? GetConnection(string connectionId)
|
||||
{
|
||||
return _connections.TryGetValue(connectionId, out var conn) ? conn : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active connections.
|
||||
/// </summary>
|
||||
public IEnumerable<TcpConnection> GetConnections() => _connections.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active connections.
|
||||
/// </summary>
|
||||
public int ConnectionCount => _connections.Count;
|
||||
|
||||
private static string GenerateConnectionId(TcpClient client)
|
||||
{
|
||||
var endpoint = client.Client.RemoteEndPoint as IPEndPoint;
|
||||
if (endpoint is not null)
|
||||
{
|
||||
return $"tcp-{endpoint.Address}-{endpoint.Port}-{Guid.NewGuid():N}".Substring(0, 32);
|
||||
}
|
||||
|
||||
return $"tcp-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping TCP transport server");
|
||||
|
||||
if (_serverCts is not null)
|
||||
{
|
||||
await _serverCts.CancelAsync();
|
||||
}
|
||||
|
||||
_listener?.Stop();
|
||||
|
||||
if (_acceptTask is not null)
|
||||
{
|
||||
await _acceptTask;
|
||||
}
|
||||
|
||||
// Close all connections
|
||||
foreach (var connection in _connections.Values)
|
||||
{
|
||||
connection.Close();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
|
||||
_connections.Clear();
|
||||
|
||||
_logger.LogInformation("TCP transport server stopped");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
await StopAsync(CancellationToken.None);
|
||||
|
||||
_listener?.Dispose();
|
||||
_serverCts?.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user