mirror of
https://github.com/Stone-Red-Code/EchoHub.git
synced 2026-09-04 00:56:05 +02:00
244 lines
8.6 KiB
C#
244 lines
8.6 KiB
C#
using Microsoft.AspNetCore.SignalR.Client;
|
|
|
|
namespace EchoHub.Server.Services;
|
|
|
|
public sealed class ServerDirectoryService : BackgroundService
|
|
{
|
|
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
|
|
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
|
|
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
|
|
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
|
|
|
|
private readonly IConfiguration _configuration;
|
|
private readonly PresenceTracker _presenceTracker;
|
|
private readonly ILogger<ServerDirectoryService> _logger;
|
|
|
|
private HubConnection? _connection;
|
|
private int _lastReportedUserCount = -1;
|
|
|
|
public ServerDirectoryService(
|
|
IConfiguration configuration,
|
|
PresenceTracker presenceTracker,
|
|
ILogger<ServerDirectoryService> logger)
|
|
{
|
|
_configuration = configuration;
|
|
_presenceTracker = presenceTracker;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
// Yield to let the host finish starting before we log or connect
|
|
await Task.Yield();
|
|
|
|
var isPublic = _configuration.GetValue<bool>("Server:PublicServer");
|
|
if (!isPublic)
|
|
{
|
|
_logger.LogInformation("PublicServer is disabled — not registering with directory");
|
|
return;
|
|
}
|
|
|
|
var host = _configuration["Server:PublicHost"];
|
|
|
|
if (string.IsNullOrWhiteSpace(host))
|
|
{
|
|
_logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration");
|
|
return;
|
|
}
|
|
|
|
var serverName = _configuration["Server:Name"] ?? "EchoHub Server";
|
|
var description = _configuration["Server:Description"];
|
|
|
|
_logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
|
|
|
|
// Outer loop: rebuilds the connection if automatic reconnect permanently fails
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
var connection = BuildConnection();
|
|
_connection = connection;
|
|
|
|
try
|
|
{
|
|
var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
connection.On("Ping", async () =>
|
|
{
|
|
_logger.LogDebug("Received alive check from directory — sending heartbeat");
|
|
try
|
|
{
|
|
await connection.InvokeAsync("Heartbeat");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send heartbeat response");
|
|
}
|
|
});
|
|
|
|
connection.Reconnected += async _ =>
|
|
{
|
|
_logger.LogInformation("Reconnected to directory — re-registering server");
|
|
_lastReportedUserCount = -1;
|
|
await RegisterAsync(serverName, description, host);
|
|
};
|
|
|
|
connection.Closed += ex =>
|
|
{
|
|
if (ex is not null)
|
|
_logger.LogWarning(ex, "Directory connection permanently closed — will rebuild");
|
|
else
|
|
_logger.LogWarning("Directory connection permanently closed — will rebuild");
|
|
|
|
connectionPermanentlyClosed.TrySetResult();
|
|
return Task.CompletedTask;
|
|
};
|
|
|
|
// Connect with retry
|
|
if (!await ConnectWithRetryAsync(connection, stoppingToken))
|
|
return;
|
|
|
|
_logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
|
await RegisterAsync(serverName, description, host);
|
|
|
|
// Poll user count until the connection is permanently closed or cancellation
|
|
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
|
|
|
if (stoppingToken.IsCancellationRequested)
|
|
return;
|
|
|
|
// Connection was permanently closed — wait briefly then rebuild
|
|
_logger.LogInformation("Rebuilding directory connection...");
|
|
await Task.Delay(ReconnectBaseDelay, stoppingToken);
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
finally
|
|
{
|
|
_connection = null;
|
|
await DisposeConnectionAsync(connection);
|
|
}
|
|
}
|
|
}
|
|
|
|
private HubConnection BuildConnection()
|
|
{
|
|
return new HubConnectionBuilder()
|
|
.WithUrl(DirectoryHubUrl)
|
|
.WithAutomaticReconnect(new InfiniteRetryPolicy())
|
|
.Build();
|
|
}
|
|
|
|
private async Task<bool> ConnectWithRetryAsync(HubConnection connection, CancellationToken ct)
|
|
{
|
|
var attempt = 0;
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
await connection.StartAsync(ct);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
attempt++;
|
|
var delay = GetBackoffDelay(attempt);
|
|
_logger.LogWarning(ex, "Failed to connect to directory — retrying in {Delay}s", delay.TotalSeconds);
|
|
await Task.Delay(delay, ct);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private async Task PollUserCountAsync(HubConnection connection, Task connectionClosed, CancellationToken ct)
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
var delayTask = Task.Delay(UpdateInterval, ct);
|
|
var completed = await Task.WhenAny(delayTask, connectionClosed);
|
|
|
|
if (completed == connectionClosed)
|
|
return;
|
|
|
|
// Observe the delay task (may throw if cancelled)
|
|
try { await delayTask; }
|
|
catch (OperationCanceledException) { return; }
|
|
|
|
if (connection.State != HubConnectionState.Connected)
|
|
continue;
|
|
|
|
var currentCount = _presenceTracker.GetOnlineUserCount();
|
|
|
|
try
|
|
{
|
|
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
|
_lastReportedUserCount = currentCount;
|
|
_logger.LogDebug("Updated directory user count to {Count}", currentCount);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to update user count on directory");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static TimeSpan GetBackoffDelay(int attempt)
|
|
{
|
|
var delay = TimeSpan.FromSeconds(Math.Pow(2, Math.Min(attempt, 10)));
|
|
return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay;
|
|
}
|
|
|
|
private async Task RegisterAsync(string name, string? description, string host)
|
|
{
|
|
if (_connection?.State != HubConnectionState.Connected)
|
|
return;
|
|
|
|
try
|
|
{
|
|
var userCount = _presenceTracker.GetOnlineUserCount();
|
|
var dto = new RegisterServerDto(name, description, host, userCount);
|
|
await _connection.InvokeAsync("RegisterServer", dto);
|
|
_lastReportedUserCount = userCount;
|
|
_logger.LogInformation("Registered with directory as {Name} at {Host}", name, host);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to register with directory");
|
|
}
|
|
}
|
|
|
|
private static async Task DisposeConnectionAsync(HubConnection connection)
|
|
{
|
|
try
|
|
{
|
|
await connection.DisposeAsync()
|
|
.AsTask().WaitAsync(TimeSpan.FromSeconds(3));
|
|
}
|
|
catch
|
|
{
|
|
// Don't let a slow dispose block shutdown
|
|
}
|
|
}
|
|
|
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
await base.StopAsync(cancellationToken);
|
|
_connection = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retries indefinitely with exponential backoff capped at 30 seconds.
|
|
/// </summary>
|
|
private sealed class InfiniteRetryPolicy : IRetryPolicy
|
|
{
|
|
public TimeSpan? NextRetryDelay(RetryContext retryContext)
|
|
{
|
|
var delay = TimeSpan.FromSeconds(Math.Pow(2, Math.Min(retryContext.PreviousRetryCount, 10)));
|
|
return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay;
|
|
}
|
|
}
|
|
}
|
|
|
|
internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount);
|