From 552fe6afa371079059e221f91553a8876e4d167b Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 13:01:51 +0100 Subject: [PATCH] feat: Release v0.2.2 with shutdown improvements and connection handling fixes --- docs/changelog/index.md | 1 + docs/changelog/toc.yml | 2 + docs/changelog/v0.2.2.md | 7 ++ src/Directory.Build.props | 2 +- src/EchoHub.Server/Program.cs | 4 + .../Services/ServerDirectoryService.cs | 91 ++++++++++++------- 6 files changed, 72 insertions(+), 35 deletions(-) create mode 100644 docs/changelog/v0.2.2.md diff --git a/docs/changelog/index.md b/docs/changelog/index.md index e090729..3967dfa 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,6 +4,7 @@ Release history for EchoHub. ## Releases +- [v0.2.2](v0.2.2.md) - Shutdown Fix - [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes - [v0.2.0](v0.2.0.md) - IRC Gateway - [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index 031eebb..da8e733 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.2 + href: v0.2.2.md - name: v0.2.1 href: v0.2.1.md - name: v0.2.0 diff --git a/docs/changelog/v0.2.2.md b/docs/changelog/v0.2.2.md new file mode 100644 index 0000000..1c99749 --- /dev/null +++ b/docs/changelog/v0.2.2.md @@ -0,0 +1,7 @@ +# v0.2.2 - Shutdown Fix + +## Fixes + +- Actually fixed server hanging on Ctrl+C — replaced `await using` with explicit dispose bounded to 3 seconds, so a stuck `HubConnection` can no longer block shutdown +- Reduced host shutdown timeout from 30s (default) to 5s +- Caught `OperationCanceledException` in the directory service reconnect loop so cancellation exits immediately instead of propagating through dispose diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 575345a..f79de17 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.1 + 0.2.2 true $(NoWarn);CS1591 diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 33ca3b8..f5a38a9 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -35,6 +35,10 @@ while (true) { var builder = WebApplication.CreateBuilder(args); + // ── Host options ──────────────────────────────────────────────────── + builder.Services.Configure(options => + options.ShutdownTimeout = TimeSpan.FromSeconds(5)); + // ── Serilog ────────────────────────────────────────────────────────── builder.Host.UseSerilog((context, config) => config.ReadFrom.Configuration(context.Configuration)); diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 9aa1e86..0ea5792 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -43,46 +43,57 @@ public sealed class ServerDirectoryService( // Outer loop: rebuilds the connection if automatic reconnect permanently fails while (!stoppingToken.IsCancellationRequested) { - await using var connection = BuildConnection(); + var connection = BuildConnection(); _connection = connection; - var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - connection.Reconnected += async _ => + try { - logger.LogInformation("Reconnected to directory — re-registering server"); - _lastReportedUserCount = -1; + 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); - }; - connection.Closed += ex => + // 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) { - 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); + } + finally + { + _connection = null; + await DisposeConnectionAsync(connection); + } } } @@ -175,9 +186,21 @@ public sealed class ServerDirectoryService( } } + 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) { - // Cancel ExecuteAsync first — it disposes the connection via await using await base.StopAsync(cancellationToken); _connection = null; }