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,27 @@
namespace StellaOps.Router.Transport.Udp;
/// <summary>
/// Exception thrown when a payload exceeds the maximum datagram size.
/// </summary>
public sealed class PayloadTooLargeException : Exception
{
/// <summary>
/// Gets the actual size of the payload.
/// </summary>
public int ActualSize { get; }
/// <summary>
/// Gets the maximum allowed size.
/// </summary>
public int MaxSize { get; }
/// <summary>
/// Initializes a new instance of the <see cref="PayloadTooLargeException"/> class.
/// </summary>
public PayloadTooLargeException(int actualSize, int maxSize)
: base($"Payload size {actualSize} exceeds maximum datagram size of {maxSize} bytes")
{
ActualSize = actualSize;
MaxSize = maxSize;
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Router.Common.Abstractions;
namespace StellaOps.Router.Transport.Udp;
/// <summary>
/// Extension methods for registering UDP transport services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds UDP 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 AddUdpTransportServer(
this IServiceCollection services,
Action<UdpTransportOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}
services.AddSingleton<UdpTransportServer>();
services.AddSingleton<ITransportServer>(sp => sp.GetRequiredService<UdpTransportServer>());
return services;
}
/// <summary>
/// Adds UDP 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 AddUdpTransportClient(
this IServiceCollection services,
Action<UdpTransportOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}
services.AddSingleton<UdpTransportClient>();
services.AddSingleton<ITransportClient>(sp => sp.GetRequiredService<UdpTransportClient>());
services.AddSingleton<IMicroserviceTransport>(sp => sp.GetRequiredService<UdpTransportClient>());
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.Udp</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,79 @@
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Transport.Udp;
/// <summary>
/// Handles serialization and deserialization of frames for UDP transport.
/// Frame format: [1-byte frame type][16-byte correlation GUID][remaining data]
/// </summary>
public static class UdpFrameProtocol
{
private const int FrameTypeSize = 1;
private const int CorrelationIdSize = 16;
private const int HeaderSize = FrameTypeSize + CorrelationIdSize;
/// <summary>
/// Parses a frame from a datagram.
/// </summary>
/// <param name="data">The datagram data.</param>
/// <returns>The parsed frame.</returns>
/// <exception cref="InvalidOperationException">Thrown when the datagram is too small.</exception>
public static Frame ParseFrame(ReadOnlySpan<byte> data)
{
if (data.Length < HeaderSize)
{
throw new InvalidOperationException(
$"Datagram too small: {data.Length} bytes, minimum is {HeaderSize}");
}
var frameType = (FrameType)data[0];
var correlationId = new Guid(data.Slice(FrameTypeSize, CorrelationIdSize));
var payload = data.Length > HeaderSize
? data[HeaderSize..].ToArray()
: Array.Empty<byte>();
return new Frame
{
Type = frameType,
CorrelationId = correlationId.ToString("N"),
Payload = payload
};
}
/// <summary>
/// Serializes a frame to a datagram.
/// </summary>
/// <param name="frame">The frame to serialize.</param>
/// <returns>The serialized datagram bytes.</returns>
public static byte[] SerializeFrame(Frame frame)
{
// Parse or generate correlation ID
var correlationGuid = frame.CorrelationId is not null &&
Guid.TryParse(frame.CorrelationId, out var parsed)
? parsed
: Guid.NewGuid();
var payloadLength = frame.Payload.Length;
var buffer = new byte[HeaderSize + payloadLength];
// Write frame type
buffer[0] = (byte)frame.Type;
// Write correlation ID
correlationGuid.TryWriteBytes(buffer.AsSpan(FrameTypeSize, CorrelationIdSize));
// Write payload
if (payloadLength > 0)
{
frame.Payload.Span.CopyTo(buffer.AsSpan(HeaderSize));
}
return buffer;
}
/// <summary>
/// Gets the header size for UDP frames.
/// </summary>
public static int GetHeaderSize() => HeaderSize;
}

View File

@@ -0,0 +1,412 @@
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.Udp;
/// <summary>
/// UDP transport client implementation for microservices.
/// UDP transport does not support streaming.
/// </summary>
public sealed class UdpTransportClient : ITransportClient, IMicroserviceTransport, IAsyncDisposable
{
private readonly UdpTransportOptions _options;
private readonly ILogger<UdpTransportClient> _logger;
private readonly ConcurrentDictionary<Guid, TaskCompletionSource<Frame>> _pendingRequests = new();
private readonly ConcurrentDictionary<string, CancellationTokenSource> _inflightHandlers = new();
private readonly CancellationTokenSource _clientCts = new();
private UdpClient? _client;
private Task? _receiveTask;
private bool _disposed;
private string? _connectionId;
/// <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="UdpTransportClient"/> class.
/// </summary>
public UdpTransportClient(
IOptions<UdpTransportOptions> options,
ILogger<UdpTransportClient> 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");
}
_client = new UdpClient
{
EnableBroadcast = _options.AllowBroadcast
};
_client.Client.ReceiveBufferSize = _options.ReceiveBufferSize;
_client.Client.SendBufferSize = _options.SendBufferSize;
_client.Connect(_options.Host, _options.Port);
_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 SendFrameInternalAsync(helloFrame, cancellationToken);
_logger.LogInformation(
"Connected to UDP 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 ReceiveLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
var result = await _client!.ReceiveAsync(cancellationToken);
var data = result.Buffer;
if (data.Length < UdpFrameProtocol.GetHeaderSize())
{
_logger.LogWarning("Received datagram too small ({Size} bytes)", data.Length);
continue;
}
var frame = UdpFrameProtocol.ParseFrame(data);
await ProcessFrameAsync(frame, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
catch (SocketException ex)
{
_logger.LogWarning(ex, "UDP socket error in receive loop");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in receive loop");
}
}
}
private async Task ProcessFrameAsync(Frame frame, CancellationToken cancellationToken)
{
switch (frame.Type)
{
case FrameType.Request:
await HandleRequestFrameAsync(frame, cancellationToken);
break;
case FrameType.Cancel:
HandleCancelFrame(frame);
break;
case FrameType.Response:
if (frame.CorrelationId is not null &&
Guid.TryParse(frame.CorrelationId, out var correlationId))
{
if (_pendingRequests.TryRemove(correlationId, out var tcs))
{
tcs.TrySetResult(frame);
}
}
break;
case FrameType.RequestStreamData:
case FrameType.ResponseStreamData:
_logger.LogWarning(
"UDP transport does not support streaming. Frame type {Type} ignored.",
frame.Type);
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 SendFrameInternalAsync(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))
{
if (_pendingRequests.TryRemove(guid, out var tcs))
{
tcs.TrySetCanceled();
}
OnCancelReceived?.Invoke(guid, null);
}
}
private async Task SendFrameInternalAsync(Frame frame, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var data = UdpFrameProtocol.SerializeFrame(frame);
if (data.Length > _options.MaxDatagramSize)
{
throw new PayloadTooLargeException(data.Length, _options.MaxDatagramSize);
}
await _client!.SendAsync(data, cancellationToken);
}
/// <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 tcs = new TaskCompletionSource<Frame>(TaskCreationOptions.RunContinuationsAsynchronously);
var registration = timeoutCts.Token.Register(() =>
{
if (_pendingRequests.TryRemove(correlationId, out var pendingTcs))
{
pendingTcs.TrySetCanceled(timeoutCts.Token);
}
});
_pendingRequests[correlationId] = tcs;
try
{
await SendFrameInternalAsync(framedRequest, timeoutCts.Token);
return await tcs.Task;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException($"Request {correlationId} timed out after {timeout}");
}
finally
{
await registration.DisposeAsync();
_pendingRequests.TryRemove(correlationId, out _);
}
}
/// <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
};
// Best effort - UDP may not deliver
await SendFrameInternalAsync(cancelFrame, CancellationToken.None);
_logger.LogDebug("Sent CANCEL for {CorrelationId} (best effort)", correlationId);
}
/// <inheritdoc />
public Task SendStreamingAsync(
ConnectionState connection,
Frame requestHeader,
Stream requestBody,
Func<Stream, Task> readResponseBody,
PayloadLimits limits,
CancellationToken cancellationToken)
{
throw new NotSupportedException(
"UDP transport does not support streaming. Use TCP or TLS transport.");
}
/// <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 SendFrameInternalAsync(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");
// Cancel all pending requests
foreach (var kvp in _pendingRequests)
{
if (_pendingRequests.TryRemove(kvp.Key, out var tcs))
{
tcs.TrySetCanceled();
}
}
await _clientCts.CancelAsync();
if (_receiveTask is not null)
{
try
{
await _receiveTask;
}
catch
{
// Ignore
}
}
_client?.Dispose();
_logger.LogInformation("Disconnected from UDP gateway");
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await DisconnectAsync();
_clientCts.Dispose();
}
}

View File

@@ -0,0 +1,50 @@
using System.Net;
namespace StellaOps.Router.Transport.Udp;
/// <summary>
/// Options for UDP transport configuration.
/// </summary>
public sealed class UdpTransportOptions
{
/// <summary>
/// Gets or sets the bind address for the server.
/// </summary>
public IPAddress BindAddress { get; set; } = IPAddress.Any;
/// <summary>
/// Gets or sets the port to listen on/connect to.
/// </summary>
public int Port { get; set; } = 5102;
/// <summary>
/// Gets or sets the host to connect to (client only).
/// </summary>
public string? Host { get; set; }
/// <summary>
/// Gets or sets the maximum datagram size in bytes.
/// Conservative default well under typical MTU of 1500 bytes.
/// </summary>
public int MaxDatagramSize { get; set; } = 8192;
/// <summary>
/// Gets or sets the default timeout for requests.
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Gets or sets whether to allow broadcast.
/// </summary>
public bool AllowBroadcast { get; set; } = false;
/// <summary>
/// Gets or sets the receive buffer size.
/// </summary>
public int ReceiveBufferSize { get; set; } = 64 * 1024;
/// <summary>
/// Gets or sets the send buffer size.
/// </summary>
public int SendBufferSize { get; set; } = 64 * 1024;
}

View File

@@ -0,0 +1,266 @@
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.Udp;
/// <summary>
/// UDP transport server implementation for the gateway.
/// UDP transport is stateless - connections are logical based on source endpoint.
/// </summary>
public sealed class UdpTransportServer : ITransportServer, IAsyncDisposable
{
private readonly UdpTransportOptions _options;
private readonly ILogger<UdpTransportServer> _logger;
private readonly ConcurrentDictionary<IPEndPoint, string> _endpointToConnectionId = new();
private readonly ConcurrentDictionary<string, (IPEndPoint Endpoint, ConnectionState State)> _connections = new();
private UdpClient? _listener;
private CancellationTokenSource? _serverCts;
private Task? _receiveTask;
private bool _disposed;
/// <summary>
/// Event raised when a connection is established (on first HELLO).
/// </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="UdpTransportServer"/> class.
/// </summary>
public UdpTransportServer(
IOptions<UdpTransportOptions> options,
ILogger<UdpTransportServer> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_serverCts = new CancellationTokenSource();
var endpoint = new IPEndPoint(_options.BindAddress, _options.Port);
_listener = new UdpClient(endpoint)
{
EnableBroadcast = _options.AllowBroadcast
};
// Configure socket buffers
_listener.Client.ReceiveBufferSize = _options.ReceiveBufferSize;
_listener.Client.SendBufferSize = _options.SendBufferSize;
_logger.LogInformation(
"UDP transport server listening on {Address}:{Port}",
_options.BindAddress,
_options.Port);
_receiveTask = ReceiveLoopAsync(_serverCts.Token);
return Task.CompletedTask;
}
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
var result = await _listener!.ReceiveAsync(cancellationToken);
var remoteEndpoint = result.RemoteEndPoint;
var data = result.Buffer;
if (data.Length < UdpFrameProtocol.GetHeaderSize())
{
_logger.LogWarning(
"Received datagram too small ({Size} bytes) from {Endpoint}",
data.Length,
remoteEndpoint);
continue;
}
// Parse frame
var frame = UdpFrameProtocol.ParseFrame(data);
// Get or create connection ID for this endpoint
var connectionId = _endpointToConnectionId.GetOrAdd(
remoteEndpoint,
_ => $"udp-{remoteEndpoint.Address}-{remoteEndpoint.Port}-{Guid.NewGuid():N}"[..32]);
// Handle HELLO specially to register connection
if (frame.Type == FrameType.Hello && !_connections.ContainsKey(connectionId))
{
var state = new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = connectionId,
ServiceName = "unknown",
Version = "1.0.0",
Region = "default"
},
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = TransportType.Udp
};
_connections[connectionId] = (remoteEndpoint, state);
_logger.LogInformation(
"UDP connection established: {ConnectionId} from {Endpoint}",
connectionId,
remoteEndpoint);
OnConnection?.Invoke(connectionId, state);
}
// Update heartbeat timestamp on HEARTBEAT frames
if (frame.Type == FrameType.Heartbeat &&
_connections.TryGetValue(connectionId, out var conn))
{
conn.State.LastHeartbeatUtc = DateTime.UtcNow;
}
OnFrame?.Invoke(connectionId, frame);
}
catch (OperationCanceledException)
{
// Expected on shutdown
break;
}
catch (ObjectDisposedException)
{
// Listener disposed
break;
}
catch (SocketException ex)
{
_logger.LogWarning(ex, "UDP socket error");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error receiving UDP datagram");
}
}
}
/// <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)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_connections.TryGetValue(connectionId, out var conn))
{
throw new InvalidOperationException($"Connection {connectionId} not found");
}
var data = UdpFrameProtocol.SerializeFrame(frame);
if (data.Length > _options.MaxDatagramSize)
{
throw new PayloadTooLargeException(data.Length, _options.MaxDatagramSize);
}
await _listener!.SendAsync(data, conn.Endpoint, cancellationToken);
}
/// <summary>
/// Gets the connection state by ID.
/// </summary>
/// <param name="connectionId">The connection ID.</param>
/// <returns>The connection state, or null if not found.</returns>
public ConnectionState? GetConnectionState(string connectionId)
{
return _connections.TryGetValue(connectionId, out var conn) ? conn.State : null;
}
/// <summary>
/// Gets all active connections.
/// </summary>
public IEnumerable<ConnectionState> GetConnections() =>
_connections.Values.Select(c => c.State);
/// <summary>
/// Gets the number of active connections.
/// </summary>
public int ConnectionCount => _connections.Count;
/// <summary>
/// Removes a connection (for cleanup purposes).
/// </summary>
/// <param name="connectionId">The connection ID.</param>
public void RemoveConnection(string connectionId)
{
if (_connections.TryRemove(connectionId, out var conn))
{
_endpointToConnectionId.TryRemove(conn.Endpoint, out _);
_logger.LogInformation("UDP connection removed: {ConnectionId}", connectionId);
OnDisconnection?.Invoke(connectionId);
}
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping UDP transport server");
if (_serverCts is not null)
{
await _serverCts.CancelAsync();
}
_listener?.Close();
if (_receiveTask is not null)
{
try
{
await _receiveTask;
}
catch (OperationCanceledException)
{
// Expected
}
}
_connections.Clear();
_endpointToConnectionId.Clear();
_logger.LogInformation("UDP transport server stopped");
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await StopAsync(CancellationToken.None);
_listener?.Dispose();
_serverCts?.Dispose();
}
}