Add unit tests for Router configuration and transport layers
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

- 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:
StellaOps Bot
2025-12-05 08:01:47 +02:00
parent 635c70e828
commit 6a299d231f
294 changed files with 28434 additions and 1329 deletions

View 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;
}
}

View File

@@ -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)));
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View 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();
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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();
}
}