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 _logger; private HubConnection? _connection; private int _lastReportedUserCount = -1; public ServerDirectoryService( IConfiguration configuration, PresenceTracker presenceTracker, ILogger 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("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 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; } /// /// Retries indefinitely with exponential backoff capped at 30 seconds. /// 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);