From ff184da095703ae38f637c6d57c4a7411f1e1407 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 10:44:00 +0100 Subject: [PATCH 1/2] feat: Enhance server connection handling with exponential backoff and improved reconnection logic --- .github/workflows/ci-release.yml | 40 +++++- .../Services/ServerDirectoryService.cs | 133 +++++++++++++----- 2 files changed, 136 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index a60b987..d756bbf 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -47,8 +47,8 @@ jobs: env: BEFORE: ${{ github.event.before }} run: | - if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then - # Initial push — treat everything as changed + if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + # workflow_dispatch or initial push — treat everything as changed echo "src_changed=true" >> "$GITHUB_OUTPUT" else SRC_CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l) @@ -92,6 +92,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup .NET 10 uses: actions/setup-dotnet@v4 @@ -161,12 +163,44 @@ jobs: zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/ zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/ + - name: Build release notes + if: steps.check_release.outputs.exists == 'false' + id: notes + run: | + TAG="${{ steps.version.outputs.tag }}" + CHANGELOG_URL="https://huebyte.github.io/EchoHub/changelog/${TAG}.html" + REPO="https://github.com/${{ github.repository }}" + PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1) + + { + echo "body<> "$GITHUB_OUTPUT" + - name: Create GitHub Release if: steps.check_release.outputs.exists == 'false' run: | gh release create "${{ steps.version.outputs.tag }}" \ --title "EchoHub ${{ steps.version.outputs.tag }}" \ - --generate-notes \ + --notes "${{ steps.notes.outputs.body }}" \ EchoHub-Server-win-x64.zip \ EchoHub-Server-linux-x64.zip \ EchoHub-Server-osx-x64.zip \ diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 01f384a..f8734a9 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -9,6 +9,8 @@ public sealed class ServerDirectoryService( { 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 HubConnection? _connection; private int _lastReportedUserCount = -1; @@ -38,52 +40,97 @@ public sealed class ServerDirectoryService( logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host); - _connection = new HubConnectionBuilder() - .WithUrl(DirectoryHubUrl) - .WithAutomaticReconnect() - .Build(); - - _connection.Reconnected += async _ => - { - logger.LogInformation("Reconnected to directory — re-registering server"); - await RegisterAsync(serverName, description, host); - }; - - _connection.Closed += ex => - { - if (ex is not null) - logger.LogWarning(ex, "Directory connection closed with error"); - return Task.CompletedTask; - }; - - // Initial connection with retry + // Outer loop: rebuilds the connection if automatic reconnect permanently fails while (!stoppingToken.IsCancellationRequested) + { + await using var connection = BuildConnection(); + _connection = connection; + + var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + 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 + _connection = null; + logger.LogInformation("Rebuilding directory connection..."); + await Task.Delay(ReconnectBaseDelay, stoppingToken); + } + } + + 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(stoppingToken); - logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl); - break; + await connection.StartAsync(ct); + return true; } catch (Exception ex) { - logger.LogWarning(ex, "Failed to connect to directory — retrying in 30s"); - await Task.Delay(UpdateInterval, stoppingToken); + attempt++; + var delay = GetBackoffDelay(attempt); + logger.LogWarning(ex, "Failed to connect to directory — retrying in {Delay}s", delay.TotalSeconds); + await Task.Delay(delay, ct); } } - if (stoppingToken.IsCancellationRequested) - return; + return false; + } - // Register on first connect - await RegisterAsync(serverName, description, host); - - // Poll user count and send updates - while (!stoppingToken.IsCancellationRequested) + private async Task PollUserCountAsync(HubConnection connection, Task connectionClosed, CancellationToken ct) + { + while (!ct.IsCancellationRequested) { - await Task.Delay(UpdateInterval, stoppingToken); + var delayTask = Task.Delay(UpdateInterval, ct); + var completed = await Task.WhenAny(delayTask, connectionClosed); - if (_connection.State != HubConnectionState.Connected) + 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(); @@ -92,7 +139,7 @@ public sealed class ServerDirectoryService( try { - await _connection.InvokeAsync("UpdateUserCount", currentCount, stoppingToken); + await connection.InvokeAsync("UpdateUserCount", currentCount, ct); _lastReportedUserCount = currentCount; logger.LogDebug("Updated directory user count to {Count}", currentCount); } @@ -103,6 +150,12 @@ public sealed class ServerDirectoryService( } } + 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) @@ -132,6 +185,18 @@ public sealed class ServerDirectoryService( await base.StopAsync(cancellationToken); } + + /// + /// 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); From efc6aa8a024a95f67782fe9e2220701090c968e2 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 10:46:45 +0100 Subject: [PATCH 2/2] feat: Update changelog for v0.1.1 release and increment version to 0.1.1 --- docs/changelog/index.md | 1 + docs/changelog/toc.yml | 2 ++ docs/changelog/v0.1.1.md | 7 +++++++ src/Directory.Build.props | 2 +- 4 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 docs/changelog/v0.1.1.md diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 475e675..90c9628 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,4 +4,5 @@ Release history for EchoHub. ## Releases +- [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing - [v0.1.0](v0.1.0.md) - Initial Release diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index 007ee93..32cbada 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,4 +1,6 @@ - name: Overview href: index.md +- name: v0.1.1 + href: v0.1.1.md - name: v0.1.0 href: v0.1.0.md diff --git a/docs/changelog/v0.1.1.md b/docs/changelog/v0.1.1.md new file mode 100644 index 0000000..50bad81 --- /dev/null +++ b/docs/changelog/v0.1.1.md @@ -0,0 +1,7 @@ +# v0.1.1 - Directory Connection Self-Healing + +## Fixes + +- Server now reconnects to the EchoHubSpace directory indefinitely when the API goes down, using exponential backoff (2s → 30s cap) +- If automatic reconnect is exhausted, the connection is rebuilt from scratch automatically +- Initial connection attempts also use exponential backoff instead of a fixed 30s delay diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 9855d0c..499303e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.0 + 0.1.1 true $(NoWarn);CS1591