From 552fe6afa371079059e221f91553a8876e4d167b Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 13:01:51 +0100 Subject: [PATCH 01/10] 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; } From caba400f435b3f61ca572aa3b3f168c588a86bf2 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 13:22:13 +0100 Subject: [PATCH 02/10] Temp diagnostics --- src/EchoHub.Server.Irc/IrcGatewayService.cs | 4 ++++ src/EchoHub.Server/Program.cs | 8 ++++++++ .../Services/ServerDirectoryService.cs | 15 +++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index 894f212..4292ef8 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -38,7 +38,9 @@ public sealed class IrcGatewayService : BackgroundService protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + Console.Error.WriteLine("[DIAG] IrcGatewayService.ExecuteAsync entered."); await Task.Yield(); + Console.Error.WriteLine($"[DIAG] IrcGatewayService.ExecuteAsync resumed after Task.Yield(). Enabled={_options.Enabled}"); if (!_options.Enabled) { @@ -141,8 +143,10 @@ public sealed class IrcGatewayService : BackgroundService public override async Task StopAsync(CancellationToken cancellationToken) { + Console.Error.WriteLine("[DIAG] IrcGatewayService.StopAsync entered."); // Cancel ExecuteAsync first so listeners stop accepting await base.StopAsync(cancellationToken); + Console.Error.WriteLine("[DIAG] IrcGatewayService.StopAsync: base.StopAsync returned."); // Force-close any remaining client connections foreach (var (_, conn) in _connections) diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index f5a38a9..8cba120 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -169,7 +169,9 @@ while (true) await using var app = builder.Build(); // ── Database initialization ────────────────────────────────────────── + Console.Error.WriteLine("[DIAG] Database initialization starting..."); await DatabaseSetup.InitializeAsync(app.Services); + Console.Error.WriteLine("[DIAG] Database initialization complete."); // ── Middleware ──────────────────────────────────────────────────────── app.UseCors(); @@ -181,7 +183,9 @@ while (true) app.MapControllers(); app.MapHub(HubConstants.ChatHubPath); + Console.Error.WriteLine("[DIAG] Calling app.RunAsync()..."); await app.RunAsync(); + Console.Error.WriteLine("[DIAG] app.RunAsync() returned."); // Graceful shutdown (Ctrl+C) — exit the loop Log.Information("Server shut down gracefully"); @@ -189,6 +193,8 @@ while (true) } catch (Exception ex) { + Console.Error.WriteLine($"[DIAG] Top-level exception: {ex}"); + var uptime = DateTimeOffset.UtcNow - startTime; // If server ran for over 60 seconds, it's a runtime crash — reset failure count @@ -211,4 +217,6 @@ while (true) } } +Console.Error.WriteLine("[DIAG] Calling Log.CloseAndFlush()..."); Log.CloseAndFlush(); +Console.Error.WriteLine("[DIAG] Log.CloseAndFlush() done. Exiting process."); diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 0ea5792..72dc697 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -17,10 +17,13 @@ public sealed class ServerDirectoryService( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + Console.Error.WriteLine("[DIAG] ServerDirectoryService.ExecuteAsync entered."); // Yield to let the host finish starting before we log or connect await Task.Yield(); + Console.Error.WriteLine("[DIAG] ServerDirectoryService.ExecuteAsync resumed after Task.Yield()."); var isPublic = configuration.GetValue("Server:PublicServer"); + Console.Error.WriteLine($"[DIAG] ServerDirectoryService: PublicServer={isPublic}"); if (!isPublic) { logger.LogInformation("PublicServer is disabled — not registering with directory"); @@ -28,6 +31,7 @@ public sealed class ServerDirectoryService( } var host = configuration["Server:PublicHost"]; + Console.Error.WriteLine($"[DIAG] ServerDirectoryService: PublicHost={host}"); if (string.IsNullOrWhiteSpace(host)) { @@ -48,6 +52,7 @@ public sealed class ServerDirectoryService( try { + Console.Error.WriteLine("[DIAG] ServerDirectoryService: Building new connection, entering try block."); var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); connection.Reconnected += async _ => @@ -69,9 +74,14 @@ public sealed class ServerDirectoryService( }; // Connect with retry + Console.Error.WriteLine("[DIAG] ServerDirectoryService: Calling ConnectWithRetryAsync..."); if (!await ConnectWithRetryAsync(connection, stoppingToken)) + { + Console.Error.WriteLine("[DIAG] ServerDirectoryService: ConnectWithRetryAsync returned false (cancelled)."); return; + } + Console.Error.WriteLine("[DIAG] ServerDirectoryService: Connected successfully!"); logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl); await RegisterAsync(serverName, description, host); @@ -188,20 +198,25 @@ public sealed class ServerDirectoryService( private static async Task DisposeConnectionAsync(HubConnection connection) { + Console.Error.WriteLine("[DIAG] ServerDirectoryService.DisposeConnectionAsync entered."); try { await connection.DisposeAsync() .AsTask().WaitAsync(TimeSpan.FromSeconds(3)); + Console.Error.WriteLine("[DIAG] ServerDirectoryService.DisposeConnectionAsync completed normally."); } catch { + Console.Error.WriteLine("[DIAG] ServerDirectoryService.DisposeConnectionAsync timed out or failed (3s)."); // Don't let a slow dispose block shutdown } } public override async Task StopAsync(CancellationToken cancellationToken) { + Console.Error.WriteLine("[DIAG] ServerDirectoryService.StopAsync entered."); await base.StopAsync(cancellationToken); + Console.Error.WriteLine("[DIAG] ServerDirectoryService.StopAsync: base.StopAsync returned."); _connection = null; } From 2a6dbb44611dcbe4cce4848c893ca3174e1149ec Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 13:30:36 +0100 Subject: [PATCH 03/10] feat: Add diagnostic hooks and heartbeat logging for application lifecycle events --- src/EchoHub.Server/Program.cs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 8cba120..8389ca1 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -183,9 +183,32 @@ while (true) app.MapControllers(); app.MapHub(HubConstants.ChatHubPath); - Console.Error.WriteLine("[DIAG] Calling app.RunAsync()..."); - await app.RunAsync(); - Console.Error.WriteLine("[DIAG] app.RunAsync() returned."); + // ── Diagnostic hooks ──────────────────────────────────────────────── + app.Lifetime.ApplicationStarted.Register( + () => Console.Error.WriteLine("[DIAG] ApplicationStarted fired")); + app.Lifetime.ApplicationStopping.Register( + () => Console.Error.WriteLine("[DIAG] ApplicationStopping fired")); + app.Lifetime.ApplicationStopped.Register( + () => Console.Error.WriteLine("[DIAG] ApplicationStopped fired")); + + // Heartbeat — proves the process is alive even if nothing else logs + var heartbeatCts = new CancellationTokenSource(); + _ = Task.Run(async () => + { + while (!heartbeatCts.Token.IsCancellationRequested) + { + await Task.Delay(5000, heartbeatCts.Token).ConfigureAwait(false); + Console.Error.WriteLine($"[DIAG] heartbeat {DateTimeOffset.UtcNow:HH:mm:ss}"); + } + }, heartbeatCts.Token); + + Console.Error.WriteLine("[DIAG] Calling app.StartAsync()..."); + await app.StartAsync(); + Console.Error.WriteLine("[DIAG] app.StartAsync() completed — server is running."); + + await app.WaitForShutdownAsync(); + Console.Error.WriteLine("[DIAG] WaitForShutdownAsync returned."); + heartbeatCts.Cancel(); // Graceful shutdown (Ctrl+C) — exit the loop Log.Information("Server shut down gracefully"); From c8e33c96ce1cbfcd37796f9edeb54f9e70ae830c Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 13:37:51 +0100 Subject: [PATCH 04/10] feat: Add diagnostic logging for hosted services resolution and startup timing --- src/EchoHub.Server/Program.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 8389ca1..78a066f 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -202,6 +202,15 @@ while (true) } }, heartbeatCts.Token); + // ── Enumerate hosted services (DI resolution) ──────────────────── + Console.Error.WriteLine("[DIAG] Resolving IHostedService instances from DI..."); + var hostedServices = app.Services.GetServices().ToList(); + Console.Error.WriteLine($"[DIAG] Found {hostedServices.Count} hosted services:"); + foreach (var svc in hostedServices) + Console.Error.WriteLine($"[DIAG] - {svc.GetType().FullName}"); + Console.Error.Flush(); + + // ── Start with per-service timing ───────────────────────────────── Console.Error.WriteLine("[DIAG] Calling app.StartAsync()..."); await app.StartAsync(); Console.Error.WriteLine("[DIAG] app.StartAsync() completed — server is running."); From cf5ef80c08d81ae346854de0702d77500f2af620 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 13:48:26 +0100 Subject: [PATCH 05/10] feat: Enhance diagnostic logging for service resolution during startup --- src/EchoHub.Server/Program.cs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 78a066f..17c7b85 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -202,8 +202,28 @@ while (true) } }, heartbeatCts.Token); - // ── Enumerate hosted services (DI resolution) ──────────────────── - Console.Error.WriteLine("[DIAG] Resolving IHostedService instances from DI..."); + // ── Resolve singletons one-by-one to find which one hangs ──────── + Console.Error.WriteLine("[DIAG] Resolving PresenceTracker..."); + _ = app.Services.GetRequiredService(); + Console.Error.WriteLine("[DIAG] PresenceTracker OK."); + + Console.Error.WriteLine("[DIAG] Resolving JwtTokenService..."); + _ = app.Services.GetRequiredService(); + Console.Error.WriteLine("[DIAG] JwtTokenService OK."); + + Console.Error.WriteLine("[DIAG] Resolving FileStorageService..."); + _ = app.Services.GetRequiredService(); + Console.Error.WriteLine("[DIAG] FileStorageService OK."); + + Console.Error.WriteLine("[DIAG] Resolving IChatBroadcaster..."); + _ = app.Services.GetServices().ToList(); + Console.Error.WriteLine("[DIAG] IChatBroadcaster OK."); + + Console.Error.WriteLine("[DIAG] Resolving IChatService..."); + _ = app.Services.GetRequiredService(); + Console.Error.WriteLine("[DIAG] IChatService OK."); + + Console.Error.WriteLine("[DIAG] Resolving IHostedService instances..."); var hostedServices = app.Services.GetServices().ToList(); Console.Error.WriteLine($"[DIAG] Found {hostedServices.Count} hosted services:"); foreach (var svc in hostedServices) From 0422066851735673cdc32aa4e2eb2b3928dad3ae Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 13:54:42 +0100 Subject: [PATCH 06/10] refactor: Update SignalRBroadcaster to use IServiceProvider for hub context retrieval --- .../Services/SignalRBroadcaster.cs | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs index 29d29a5..47dfd90 100644 --- a/src/EchoHub.Server/Services/SignalRBroadcaster.cs +++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs @@ -5,42 +5,53 @@ using Microsoft.AspNetCore.SignalR; namespace EchoHub.Server.Services; -public class SignalRBroadcaster( - IHubContext hubContext, - PresenceTracker presenceTracker) : IChatBroadcaster +public class SignalRBroadcaster : IChatBroadcaster { + private readonly IServiceProvider _serviceProvider; + private readonly PresenceTracker _presenceTracker; + private IHubContext? _hubContext; + + private IHubContext HubContext + => _hubContext ??= _serviceProvider.GetRequiredService>(); + + public SignalRBroadcaster(IServiceProvider serviceProvider, PresenceTracker presenceTracker) + { + _serviceProvider = serviceProvider; + _presenceTracker = presenceTracker; + } + public Task SendMessageToChannelAsync(string channelName, MessageDto message) - => hubContext.Clients.Group(channelName).ReceiveMessage(message); + => HubContext.Clients.Group(channelName).ReceiveMessage(message); public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null) { if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-")) - return hubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username); + return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username); - return hubContext.Clients.Group(channelName).UserJoined(channelName, username); + return HubContext.Clients.Group(channelName).UserJoined(channelName, username); } public Task SendUserLeftAsync(string channelName, string username) - => hubContext.Clients.Group(channelName).UserLeft(channelName, username); + => HubContext.Clients.Group(channelName).UserLeft(channelName, username); public Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null) { if (channelName is not null) - return hubContext.Clients.Group(channelName).ChannelUpdated(channel); + return HubContext.Clients.Group(channelName).ChannelUpdated(channel); - return hubContext.Clients.All.ChannelUpdated(channel); + return HubContext.Clients.All.ChannelUpdated(channel); } public Task SendUserStatusChangedAsync(List channelNames, UserPresenceDto presence) { - var connections = presenceTracker.GetConnectionsInChannels(channelNames) + var connections = _presenceTracker.GetConnectionsInChannels(channelNames) .Where(c => !c.StartsWith("irc-")) .ToList(); if (connections.Count == 0) return Task.CompletedTask; - return hubContext.Clients.Clients(connections).UserStatusChanged(presence); + return HubContext.Clients.Clients(connections).UserStatusChanged(presence); } public Task SendErrorAsync(string connectionId, string message) @@ -48,6 +59,6 @@ public class SignalRBroadcaster( if (connectionId.StartsWith("irc-")) return Task.CompletedTask; - return hubContext.Clients.Client(connectionId).Error(message); + return HubContext.Clients.Client(connectionId).Error(message); } } From 13297fd017368077971e621aad1b701a99ab6905 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 14:04:19 +0100 Subject: [PATCH 07/10] Refactor classes to use constructor injection for dependencies - Updated IrcBroadcaster to use constructor injection for IrcGatewayService. - Refactored JwtTokenService to initialize configuration values in the constructor. - Modified AuthController to use constructor injection for EchoHubDbContext and JwtTokenService. - Refactored ChannelsController to utilize constructor injection for dependencies. - Updated FilesController to use constructor injection for FileStorageService. - Refactored ServerController to initialize EchoHubDbContext and IConfiguration via constructor. - Modified UsersController to use constructor injection for EchoHubDbContext and ImageToAsciiService. - Updated EchoHubDbContext to use constructor for DbContextOptions. - Refactored ChatHub to use constructor injection for IChatService and ILogger. - Modified ChatService to utilize constructor injection for dependencies. - Refactored ServerDirectoryService to use constructor injection for IConfiguration, PresenceTracker, and ILogger. --- src/EchoHub.Server.Irc/IrcBroadcaster.cs | 23 ++++-- src/EchoHub.Server/Auth/JwtTokenService.cs | 21 +++-- .../Controllers/AuthController.cs | 44 ++++++---- .../Controllers/ChannelsController.cs | 82 +++++++++++-------- .../Controllers/FilesController.cs | 10 ++- .../Controllers/ServerController.cs | 18 ++-- .../Controllers/UsersController.cs | 22 +++-- src/EchoHub.Server/Data/EchoHubDbContext.cs | 3 +- src/EchoHub.Server/Hubs/ChatHub.cs | 43 ++++++---- src/EchoHub.Server/Services/ChatService.cs | 81 ++++++++++-------- .../Services/ServerDirectoryService.cs | 57 +++++++------ 11 files changed, 248 insertions(+), 156 deletions(-) diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 9f70d3d..4d9c1bc 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -3,13 +3,20 @@ using EchoHub.Core.DTOs; namespace EchoHub.Server.Irc; -public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster +public class IrcBroadcaster : IChatBroadcaster { + private readonly IrcGatewayService _gateway; + + public IrcBroadcaster(IrcGatewayService gateway) + { + _gateway = gateway; + } + public async Task SendMessageToChannelAsync(string channelName, MessageDto message) { var lines = IrcMessageFormatter.FormatMessage(message); - foreach (var conn in gateway.GetConnectionsInChannel(channelName)) + foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) { // IRC convention: don't echo sender's own message if (conn.Nickname == message.SenderUsername) @@ -22,7 +29,7 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null) { - foreach (var conn in gateway.GetConnectionsInChannel(channelName)) + foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) { if (conn.ConnectionId == excludeConnectionId) continue; await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}"); @@ -31,7 +38,7 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster public async Task SendUserLeftAsync(string channelName, string username) { - foreach (var conn in gateway.GetConnectionsInChannel(channelName)) + foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) { if (conn.Nickname == username) continue; await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}"); @@ -43,9 +50,9 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster var target = channelName ?? channel.Name; if (channel.Topic is null) return; - foreach (var conn in gateway.GetConnectionsInChannel(target)) + foreach (var conn in _gateway.GetConnectionsInChannel(target)) { - await conn.SendAsync($":{gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}"); + await conn.SendAsync($":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}"); } } @@ -59,9 +66,9 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster { if (!connectionId.StartsWith("irc-")) return; - if (gateway.Connections.TryGetValue(connectionId, out var conn)) + if (_gateway.Connections.TryGetValue(connectionId, out var conn)) { - await conn.SendAsync($":{gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}"); + await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}"); } } } diff --git a/src/EchoHub.Server/Auth/JwtTokenService.cs b/src/EchoHub.Server/Auth/JwtTokenService.cs index 3c0b15b..5f1e525 100644 --- a/src/EchoHub.Server/Auth/JwtTokenService.cs +++ b/src/EchoHub.Server/Auth/JwtTokenService.cs @@ -7,18 +7,25 @@ using Microsoft.IdentityModel.Tokens; namespace EchoHub.Server.Auth; -public class JwtTokenService(IConfiguration configuration) +public class JwtTokenService { - private readonly string _secret = configuration["Jwt:Secret"] - ?? throw new InvalidOperationException("Jwt:Secret is not configured."); - private readonly string _issuer = configuration["Jwt:Issuer"] - ?? throw new InvalidOperationException("Jwt:Issuer is not configured."); - private readonly string _audience = configuration["Jwt:Audience"] - ?? throw new InvalidOperationException("Jwt:Audience is not configured."); + private readonly string _secret; + private readonly string _issuer; + private readonly string _audience; private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(15); public static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30); + public JwtTokenService(IConfiguration configuration) + { + _secret = configuration["Jwt:Secret"] + ?? throw new InvalidOperationException("Jwt:Secret is not configured."); + _issuer = configuration["Jwt:Issuer"] + ?? throw new InvalidOperationException("Jwt:Issuer is not configured."); + _audience = configuration["Jwt:Audience"] + ?? throw new InvalidOperationException("Jwt:Audience is not configured."); + } + public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(User user) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); diff --git a/src/EchoHub.Server/Controllers/AuthController.cs b/src/EchoHub.Server/Controllers/AuthController.cs index aa58d76..4796cbd 100644 --- a/src/EchoHub.Server/Controllers/AuthController.cs +++ b/src/EchoHub.Server/Controllers/AuthController.cs @@ -12,8 +12,16 @@ namespace EchoHub.Server.Controllers; [ApiController] [Route("api/auth")] [EnableRateLimiting("auth")] -public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase +public class AuthController : ControllerBase { + private readonly EchoHubDbContext _db; + private readonly JwtTokenService _jwt; + + public AuthController(EchoHubDbContext db, JwtTokenService jwt) + { + _db = db; + _jwt = jwt; + } [HttpPost("register")] public async Task Register([FromBody] RegisterRequest request) { @@ -31,7 +39,7 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll var normalizedUsername = request.Username.ToLowerInvariant().Trim(); - if (await db.Users.AnyAsync(u => u.Username == normalizedUsername)) + if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername)) return Conflict(new ErrorResponse("Username is already taken.")); var user = new User @@ -42,20 +50,20 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll DisplayName = request.DisplayName?.Trim(), }; - db.Users.Add(user); - await db.SaveChangesAsync(); + _db.Users.Add(user); + await _db.SaveChangesAsync(); - var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); + var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user); var refreshToken = JwtTokenService.GenerateRefreshToken(); - db.RefreshTokens.Add(new RefreshToken + _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), TokenHash = JwtTokenService.HashToken(refreshToken), UserId = user.Id, ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), }); - await db.SaveChangesAsync(); + await _db.SaveChangesAsync(); return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); } @@ -67,25 +75,25 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll return BadRequest(new ErrorResponse("Username and password are required.")); var normalizedUsername = request.Username.ToLowerInvariant().Trim(); - var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) return Unauthorized(new ErrorResponse("Invalid username or password.")); user.LastSeenAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(); + await _db.SaveChangesAsync(); - var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); + var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user); var refreshToken = JwtTokenService.GenerateRefreshToken(); - db.RefreshTokens.Add(new RefreshToken + _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), TokenHash = JwtTokenService.HashToken(refreshToken), UserId = user.Id, ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), }); - await db.SaveChangesAsync(); + await _db.SaveChangesAsync(); return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); } @@ -97,7 +105,7 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll return BadRequest(new ErrorResponse("Refresh token is required.")); var tokenHash = JwtTokenService.HashToken(request.RefreshToken); - var storedToken = await db.RefreshTokens + var storedToken = await _db.RefreshTokens .Include(r => r.User) .FirstOrDefaultAsync(r => r.TokenHash == tokenHash); @@ -111,17 +119,17 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll user.LastSeenAt = DateTimeOffset.UtcNow; // Issue new token pair - var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); + var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user); var newRefreshToken = JwtTokenService.GenerateRefreshToken(); - db.RefreshTokens.Add(new RefreshToken + _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), TokenHash = JwtTokenService.HashToken(newRefreshToken), UserId = user.Id, ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), }); - await db.SaveChangesAsync(); + await _db.SaveChangesAsync(); return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); } @@ -133,12 +141,12 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll return BadRequest(new ErrorResponse("Refresh token is required.")); var tokenHash = JwtTokenService.HashToken(request.RefreshToken); - var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash); + var storedToken = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash); if (storedToken is not null && storedToken.IsActive) { storedToken.RevokedAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(); + await _db.SaveChangesAsync(); } return Ok(); diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 5d853d0..76f8836 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -16,22 +16,36 @@ namespace EchoHub.Server.Controllers; [Route("api/channels")] [Authorize] [EnableRateLimiting("general")] -public class ChannelsController( - EchoHubDbContext db, - FileStorageService fileStorage, - ImageToAsciiService asciiService, - IHttpClientFactory httpClientFactory, - IChatService chatService) : ControllerBase +public class ChannelsController : ControllerBase { + private readonly EchoHubDbContext _db; + private readonly FileStorageService _fileStorage; + private readonly ImageToAsciiService _asciiService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IChatService _chatService; + + public ChannelsController( + EchoHubDbContext db, + FileStorageService fileStorage, + ImageToAsciiService asciiService, + IHttpClientFactory httpClientFactory, + IChatService chatService) + { + _db = db; + _fileStorage = fileStorage; + _asciiService = asciiService; + _httpClientFactory = httpClientFactory; + _chatService = chatService; + } [HttpGet] public async Task GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) { offset = Math.Max(0, offset); limit = Math.Clamp(limit, 1, 100); - var total = await db.Channels.CountAsync(); + var total = await _db.Channels.CountAsync(); - var channels = await db.Channels + var channels = await _db.Channels .OrderBy(c => c.Name) .Skip(offset) .Take(limit) @@ -57,7 +71,7 @@ public class ChannelsController( if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.")); - if (await db.Channels.AnyAsync(c => c.Name == channelName)) + if (await _db.Channels.AnyAsync(c => c.Name == channelName)) return Conflict(new ErrorResponse($"Channel '{channelName}' already exists.")); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -72,11 +86,11 @@ public class ChannelsController( CreatedByUserId = Guid.Parse(userIdClaim), }; - db.Channels.Add(channel); - await db.SaveChangesAsync(); + _db.Channels.Add(channel); + await _db.SaveChangesAsync(); var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt); - await chatService.BroadcastChannelUpdatedAsync(dto); + await _chatService.BroadcastChannelUpdatedAsync(dto); return Created($"/api/channels/{channelName}", dto); } @@ -89,7 +103,7 @@ public class ChannelsController( return Unauthorized(new ErrorResponse("Authentication required.")); var channelName = channel.ToLowerInvariant().Trim(); - var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (dbChannel is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); @@ -101,11 +115,11 @@ public class ChannelsController( return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.")); dbChannel.Topic = request.Topic?.Trim(); - await db.SaveChangesAsync(); + await _db.SaveChangesAsync(); - var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); + var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt); - await chatService.BroadcastChannelUpdatedAsync(dto, channelName); + await _chatService.BroadcastChannelUpdatedAsync(dto, channelName); return Ok(dto); } @@ -122,7 +136,7 @@ public class ChannelsController( if (channelName == HubConstants.DefaultChannel) return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted.")); - var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (dbChannel is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); @@ -130,8 +144,8 @@ public class ChannelsController( if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim)) return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel.")); - db.Channels.Remove(dbChannel); - await db.SaveChangesAsync(); + _db.Channels.Remove(dbChannel); + await _db.SaveChangesAsync(); return NoContent(); } @@ -151,7 +165,7 @@ public class ChannelsController( if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return BadRequest(new ErrorResponse("Invalid channel name format.")); - var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (dbChannel is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); @@ -167,7 +181,7 @@ public class ChannelsController( using var stream = file.OpenReadStream(); var isImage = FileValidationHelper.IsValidImage(stream); - var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName); + var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName); var messageType = isImage ? MessageType.Image : MessageType.File; string content; @@ -175,7 +189,7 @@ public class ChannelsController( if (isImage) { using var imageStream = System.IO.File.OpenRead(filePath); - content = asciiService.ConvertToAscii(imageStream); + content = _asciiService.ConvertToAscii(imageStream); } else { @@ -183,7 +197,7 @@ public class ChannelsController( } var attachmentUrl = $"/api/files/{fileId}"; - var sender = await db.Users.FindAsync(userId); + var sender = await _db.Users.FindAsync(userId); var message = new Message { @@ -198,8 +212,8 @@ public class ChannelsController( SenderUsername = usernameClaim, }; - db.Messages.Add(message); - await db.SaveChangesAsync(); + _db.Messages.Add(message); + await _db.SaveChangesAsync(); var messageDto = new MessageDto( message.Id, @@ -212,7 +226,7 @@ public class ChannelsController( file.FileName, message.SentAt); - await chatService.BroadcastMessageAsync(channelName, messageDto); + await _chatService.BroadcastMessageAsync(channelName, messageDto); return Ok(messageDto); } @@ -232,7 +246,7 @@ public class ChannelsController( if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return BadRequest(new ErrorResponse("Invalid channel name format.")); - var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (dbChannel is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); @@ -248,7 +262,7 @@ public class ChannelsController( string fileName; try { - using var client = httpClientFactory.CreateClient("ImageDownload"); + using var client = _httpClientFactory.CreateClient("ImageDownload"); using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); @@ -291,16 +305,16 @@ public class ChannelsController( return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP.")); // Save file and convert to ASCII - var (fileId, filePath) = await fileStorage.SaveFileAsync(memoryStream, fileName); + var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName); string content; using (var imageStream = System.IO.File.OpenRead(filePath)) { - content = asciiService.ConvertToAscii(imageStream); + content = _asciiService.ConvertToAscii(imageStream); } var attachmentUrl = $"/api/files/{fileId}"; - var sender = await db.Users.FindAsync(userId); + var sender = await _db.Users.FindAsync(userId); var message = new Message { @@ -315,8 +329,8 @@ public class ChannelsController( SenderUsername = usernameClaim, }; - db.Messages.Add(message); - await db.SaveChangesAsync(); + _db.Messages.Add(message); + await _db.SaveChangesAsync(); var messageDto = new MessageDto( message.Id, @@ -329,7 +343,7 @@ public class ChannelsController( fileName, message.SentAt); - await chatService.BroadcastMessageAsync(channelName, messageDto); + await _chatService.BroadcastMessageAsync(channelName, messageDto); return Ok(messageDto); } diff --git a/src/EchoHub.Server/Controllers/FilesController.cs b/src/EchoHub.Server/Controllers/FilesController.cs index e78ae48..2364171 100644 --- a/src/EchoHub.Server/Controllers/FilesController.cs +++ b/src/EchoHub.Server/Controllers/FilesController.cs @@ -10,15 +10,21 @@ namespace EchoHub.Server.Controllers; [Route("api/files")] [Authorize] [EnableRateLimiting("general")] -public class FilesController(FileStorageService fileStorage) : ControllerBase +public class FilesController : ControllerBase { + private readonly FileStorageService _fileStorage; + + public FilesController(FileStorageService fileStorage) + { + _fileStorage = fileStorage; + } [HttpGet("{fileId}")] public IActionResult GetFile(string fileId) { if (!Guid.TryParse(fileId, out _)) return BadRequest(new ErrorResponse("Invalid file identifier.")); - var filePath = fileStorage.GetFilePath(fileId); + var filePath = _fileStorage.GetFilePath(fileId); if (filePath is null) return NotFound(new ErrorResponse("File not found.")); diff --git a/src/EchoHub.Server/Controllers/ServerController.cs b/src/EchoHub.Server/Controllers/ServerController.cs index b8f0bfa..383840b 100644 --- a/src/EchoHub.Server/Controllers/ServerController.cs +++ b/src/EchoHub.Server/Controllers/ServerController.cs @@ -7,17 +7,25 @@ namespace EchoHub.Server.Controllers; [ApiController] [Route("api/server")] -public class ServerController(EchoHubDbContext db, IConfiguration config) : ControllerBase +public class ServerController : ControllerBase { + private readonly EchoHubDbContext _db; + private readonly IConfiguration _config; + + public ServerController(EchoHubDbContext db, IConfiguration config) + { + _db = db; + _config = config; + } [HttpGet("info")] public async Task GetInfo() { - var userCount = await db.Users.CountAsync(); - var channelCount = await db.Channels.CountAsync(); + var userCount = await _db.Users.CountAsync(); + var channelCount = await _db.Channels.CountAsync(); var status = new ServerStatusDto( - config["Server:Name"] ?? "EchoHub Server", - config["Server:Description"], + _config["Server:Name"] ?? "EchoHub Server", + _config["Server:Description"], userCount, channelCount); diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index a178d8b..2d4f0c9 100644 --- a/src/EchoHub.Server/Controllers/UsersController.cs +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -14,13 +14,21 @@ namespace EchoHub.Server.Controllers; [Route("api/users")] [Authorize] [EnableRateLimiting("general")] -public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase +public class UsersController : ControllerBase { + private readonly EchoHubDbContext _db; + private readonly ImageToAsciiService _asciiService; + + public UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) + { + _db = db; + _asciiService = asciiService; + } [HttpGet("{username}/profile")] public async Task GetProfile(string username) { var normalizedUsername = username.ToLowerInvariant().Trim(); - var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); if (user is null) return NotFound(new ErrorResponse("User not found.")); @@ -36,7 +44,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi return Unauthorized(new ErrorResponse("Authentication required.")); var userId = Guid.Parse(userIdClaim); - var user = await db.Users.FindAsync(userId); + var user = await _db.Users.FindAsync(userId); if (user is null) return NotFound(new ErrorResponse("User not found.")); @@ -63,7 +71,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi user.NicknameColor = color.Length > 0 ? color : null; } - await db.SaveChangesAsync(); + await _db.SaveChangesAsync(); return Ok(ToProfileDto(user)); } @@ -77,7 +85,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi return Unauthorized(new ErrorResponse("Authentication required.")); var userId = Guid.Parse(userIdClaim); - var user = await db.Users.FindAsync(userId); + var user = await _db.Users.FindAsync(userId); if (user is null) return NotFound(new ErrorResponse("User not found.")); @@ -95,10 +103,10 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi if (!FileValidationHelper.IsValidImage(stream)) return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP.")); - var asciiArt = asciiService.ConvertToAscii(stream); + var asciiArt = _asciiService.ConvertToAscii(stream); user.AvatarAscii = asciiArt; - await db.SaveChangesAsync(); + await _db.SaveChangesAsync(); return Ok(new AvatarUploadResponse(asciiArt)); } diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index 6927cde..801dca6 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -4,8 +4,9 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace EchoHub.Server.Data; -public class EchoHubDbContext(DbContextOptions options) : DbContext(options) +public class EchoHubDbContext : DbContext { + public EchoHubDbContext(DbContextOptions options) : base(options) { } public DbSet Users => Set(); public DbSet Channels => Set(); public DbSet Messages => Set(); diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index eac6401..ee8b516 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -9,8 +9,17 @@ using Microsoft.AspNetCore.SignalR; namespace EchoHub.Server.Hubs; [Authorize] -public class ChatHub(IChatService chatService, ILogger logger) : Hub +public class ChatHub : Hub { + private readonly IChatService _chatService; + private readonly ILogger _logger; + + public ChatHub(IChatService chatService, ILogger logger) + { + _chatService = chatService; + _logger = logger; + } + private Guid CurrentUserId => Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new HubException("User ID claim not found.")); @@ -23,12 +32,12 @@ public class ChatHub(IChatService chatService, ILogger logger) : Hub logger) : Hub logger) : Hub logger) : Hub logger) : Hub logger) : Hub logger) : Hub logger) : Hub logger) : Hub broadcasters, - ILogger logger) : IChatService +public class ChatService : IChatService { + private readonly IServiceScopeFactory _scopeFactory; + private readonly PresenceTracker _presenceTracker; + private readonly IEnumerable _broadcasters; + private readonly ILogger _logger; + + public ChatService( + IServiceScopeFactory scopeFactory, + PresenceTracker presenceTracker, + IEnumerable broadcasters, + ILogger logger) + { + _scopeFactory = scopeFactory; + _presenceTracker = presenceTracker; + _broadcasters = broadcasters; + _logger = logger; + } + public async Task UserConnectedAsync(string connectionId, Guid userId, string username) { - presenceTracker.UserConnected(connectionId, userId, username); + _presenceTracker.UserConnected(connectionId, userId, username); - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var user = await db.Users.FindAsync(userId); @@ -30,21 +43,21 @@ public class ChatService( await db.SaveChangesAsync(); } - logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId); + _logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId); } public async Task UserDisconnectedAsync(string connectionId) { - var preDisconnectUsername = presenceTracker.GetUsernameForConnection(connectionId); + var preDisconnectUsername = _presenceTracker.GetUsernameForConnection(connectionId); var channelsBeforeDisconnect = preDisconnectUsername is not null - ? presenceTracker.GetChannelsForUser(preDisconnectUsername) + ? _presenceTracker.GetChannelsForUser(preDisconnectUsername) : []; - var username = presenceTracker.UserDisconnected(connectionId); + var username = _presenceTracker.UserDisconnected(connectionId); - if (username is not null && !presenceTracker.IsOnline(username)) + if (username is not null && !_presenceTracker.IsOnline(username)) { - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); @@ -65,7 +78,7 @@ public class ChatService( } } - logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId); + _logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId); return username; } @@ -77,19 +90,19 @@ public class ChatService( if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list."); - var isNewJoin = presenceTracker.JoinChannel(username, channelName); + var isNewJoin = _presenceTracker.JoinChannel(username, channelName); if (isNewJoin) { await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId)); - logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); + _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); } var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount); @@ -99,9 +112,9 @@ public class ChatService( public async Task LeaveChannelAsync(string connectionId, string username, string channelName) { channelName = channelName.ToLowerInvariant().Trim(); - presenceTracker.LeaveChannel(username, channelName); + _presenceTracker.LeaveChannel(username, channelName); await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username)); - logger.LogInformation("{User} left channel '{Channel}'", username, channelName); + _logger.LogInformation("{User} left channel '{Channel}'", username, channelName); } public async Task SendMessageAsync(Guid userId, string username, string channelName, string content) @@ -117,7 +130,7 @@ public class ChatService( if (content.Length > HubConstants.MaxMessageLength) return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); @@ -153,7 +166,7 @@ public class ChatService( await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); - logger.LogDebug("{User} sent message in '{Channel}'", username, channelName); + _logger.LogDebug("{User} sent message in '{Channel}'", username, channelName); return null; } @@ -162,7 +175,7 @@ public class ChatService( channelName = channelName.ToLowerInvariant().Trim(); count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return await GetChannelHistoryInternalAsync(db, channelName, count); @@ -173,7 +186,7 @@ public class ChatService( if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength) return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters."; - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var user = await db.Users.FindAsync(userId); @@ -192,7 +205,7 @@ public class ChatService( status, statusMessage); - var channels = presenceTracker.GetChannelsForUser(username); + var channels = _presenceTracker.GetChannelsForUser(username); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence)); return null; @@ -201,9 +214,9 @@ public class ChatService( public async Task> GetOnlineUsersAsync(string channelName) { channelName = channelName.ToLowerInvariant().Trim(); - var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName); + var onlineUsernames = _presenceTracker.GetOnlineUsersInChannel(channelName); - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return await db.Users @@ -225,7 +238,7 @@ public class ChatService( private async Task BroadcastToAllAsync(Func action) { - foreach (var broadcaster in broadcasters) + foreach (var broadcaster in _broadcasters) { try { @@ -233,7 +246,7 @@ public class ChatService( } catch (Exception ex) { - logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name); + _logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name); } } } @@ -242,7 +255,7 @@ public class ChatService( { username = username.ToLowerInvariant(); - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); @@ -258,7 +271,7 @@ public class ChatService( { channelName = channelName.ToLowerInvariant().Trim(); - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); @@ -269,7 +282,7 @@ public class ChatService( public async Task> GetChannelListAsync() { - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync(); @@ -277,17 +290,17 @@ public class ChatService( return channels.Select(c => new ChannelListItem( c.Name, c.Topic, - presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); + _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); } public Task> GetChannelsForUserAsync(string username) - => Task.FromResult(presenceTracker.GetChannelsForUser(username)); + => Task.FromResult(_presenceTracker.GetChannelsForUser(username)); public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) { username = username.ToLowerInvariant(); - using var scope = scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 72dc697..69b44ee 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -2,19 +2,30 @@ using Microsoft.AspNetCore.SignalR.Client; namespace EchoHub.Server.Services; -public sealed class ServerDirectoryService( - IConfiguration configuration, - PresenceTracker presenceTracker, - ILogger logger) : BackgroundService +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) { Console.Error.WriteLine("[DIAG] ServerDirectoryService.ExecuteAsync entered."); @@ -22,27 +33,27 @@ public sealed class ServerDirectoryService( await Task.Yield(); Console.Error.WriteLine("[DIAG] ServerDirectoryService.ExecuteAsync resumed after Task.Yield()."); - var isPublic = configuration.GetValue("Server:PublicServer"); + var isPublic = _configuration.GetValue("Server:PublicServer"); Console.Error.WriteLine($"[DIAG] ServerDirectoryService: PublicServer={isPublic}"); if (!isPublic) { - logger.LogInformation("PublicServer is disabled — not registering with directory"); + _logger.LogInformation("PublicServer is disabled — not registering with directory"); return; } - var host = configuration["Server:PublicHost"]; + var host = _configuration["Server:PublicHost"]; Console.Error.WriteLine($"[DIAG] ServerDirectoryService: PublicHost={host}"); if (string.IsNullOrWhiteSpace(host)) { - logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration"); + _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"]; + 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); + _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) @@ -57,7 +68,7 @@ public sealed class ServerDirectoryService( connection.Reconnected += async _ => { - logger.LogInformation("Reconnected to directory — re-registering server"); + _logger.LogInformation("Reconnected to directory — re-registering server"); _lastReportedUserCount = -1; await RegisterAsync(serverName, description, host); }; @@ -65,9 +76,9 @@ public sealed class ServerDirectoryService( connection.Closed += ex => { if (ex is not null) - logger.LogWarning(ex, "Directory connection permanently closed — will rebuild"); + _logger.LogWarning(ex, "Directory connection permanently closed — will rebuild"); else - logger.LogWarning("Directory connection permanently closed — will rebuild"); + _logger.LogWarning("Directory connection permanently closed — will rebuild"); connectionPermanentlyClosed.TrySetResult(); return Task.CompletedTask; @@ -82,7 +93,7 @@ public sealed class ServerDirectoryService( } Console.Error.WriteLine("[DIAG] ServerDirectoryService: Connected successfully!"); - logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl); + _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 @@ -92,7 +103,7 @@ public sealed class ServerDirectoryService( return; // Connection was permanently closed — wait briefly then rebuild - logger.LogInformation("Rebuilding directory connection..."); + _logger.LogInformation("Rebuilding directory connection..."); await Task.Delay(ReconnectBaseDelay, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) @@ -129,7 +140,7 @@ public sealed class ServerDirectoryService( { attempt++; var delay = GetBackoffDelay(attempt); - logger.LogWarning(ex, "Failed to connect to directory — retrying in {Delay}s", delay.TotalSeconds); + _logger.LogWarning(ex, "Failed to connect to directory — retrying in {Delay}s", delay.TotalSeconds); await Task.Delay(delay, ct); } } @@ -154,7 +165,7 @@ public sealed class ServerDirectoryService( if (connection.State != HubConnectionState.Connected) continue; - var currentCount = presenceTracker.GetOnlineUserCount(); + var currentCount = _presenceTracker.GetOnlineUserCount(); if (currentCount == _lastReportedUserCount) continue; @@ -162,11 +173,11 @@ public sealed class ServerDirectoryService( { await connection.InvokeAsync("UpdateUserCount", currentCount, ct); _lastReportedUserCount = currentCount; - logger.LogDebug("Updated directory user count to {Count}", currentCount); + _logger.LogDebug("Updated directory user count to {Count}", currentCount); } catch (Exception ex) { - logger.LogWarning(ex, "Failed to update user count on directory"); + _logger.LogWarning(ex, "Failed to update user count on directory"); } } } @@ -184,15 +195,15 @@ public sealed class ServerDirectoryService( try { - var userCount = presenceTracker.GetOnlineUserCount(); + 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); + _logger.LogInformation("Registered with directory as {Name} at {Host}", name, host); } catch (Exception ex) { - logger.LogWarning(ex, "Failed to register with directory"); + _logger.LogWarning(ex, "Failed to register with directory"); } } From 8dfdc163bfd6b9f71e365c73e94b5f0866a69ba7 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 14:09:56 +0100 Subject: [PATCH 08/10] refactor: Update IrcGatewayService to use IServiceProvider for dependency resolution feat: Simplify IChatBroadcaster registration in IrcServiceExtensions feat: Add diagnostic logging for IRC configuration and environment --- src/EchoHub.Server.Irc/IrcGatewayService.cs | 18 ++++++++++++------ src/EchoHub.Server.Irc/IrcServiceExtensions.cs | 3 +-- src/EchoHub.Server/Program.cs | 4 ++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index 4292ef8..5515b9c 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -4,6 +4,7 @@ using System.Net.Security; using System.Net.Sockets; using System.Security.Cryptography.X509Certificates; using EchoHub.Core.Contracts; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -13,7 +14,7 @@ namespace EchoHub.Server.Irc; public sealed class IrcGatewayService : BackgroundService { private readonly IrcOptions _options; - private readonly IChatService _chatService; + private readonly IServiceProvider _services; private readonly ILogger _logger; private readonly ConcurrentDictionary _connections = new(); @@ -22,11 +23,11 @@ public sealed class IrcGatewayService : BackgroundService public IrcGatewayService( IOptions options, - IChatService chatService, + IServiceProvider services, ILogger logger) { _options = options.Value; - _chatService = chatService; + _services = services; _logger = logger; } @@ -111,10 +112,13 @@ public sealed class IrcGatewayService : BackgroundService _logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId); + IChatService? chatService = null; + try { + chatService = _services.GetRequiredService(); var handler = new IrcCommandHandler( - connection, _options, _chatService, _logger); + connection, _options, chatService, _logger); await handler.RunAsync(ct); } @@ -128,10 +132,12 @@ public sealed class IrcGatewayService : BackgroundService { foreach (var ch in connection.JoinedChannels.ToList()) { - await _chatService.LeaveChannelAsync( + if (chatService is null) break; + await chatService.LeaveChannelAsync( connection.ConnectionId, connection.Nickname!, ch); } - await _chatService.UserDisconnectedAsync(connection.ConnectionId); + if (chatService is not null) + await chatService.UserDisconnectedAsync(connection.ConnectionId); } _connections.TryRemove(connection.ConnectionId, out _); diff --git a/src/EchoHub.Server.Irc/IrcServiceExtensions.cs b/src/EchoHub.Server.Irc/IrcServiceExtensions.cs index 14c096b..7af3647 100644 --- a/src/EchoHub.Server.Irc/IrcServiceExtensions.cs +++ b/src/EchoHub.Server.Irc/IrcServiceExtensions.cs @@ -15,8 +15,7 @@ public static class IrcServiceExtensions if (builder.Configuration.GetValue("Irc:Enabled")) { builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => - new IrcBroadcaster(sp.GetRequiredService())); + builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); } diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 17c7b85..809355e 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -202,6 +202,10 @@ while (true) } }, heartbeatCts.Token); + // ── Config diagnostics ───────────────────────────────────────────── + Console.Error.WriteLine($"[DIAG] Irc:Enabled = {app.Configuration.GetValue("Irc:Enabled")}"); + Console.Error.WriteLine($"[DIAG] Environment = {app.Environment.EnvironmentName}"); + // ── Resolve singletons one-by-one to find which one hangs ──────── Console.Error.WriteLine("[DIAG] Resolving PresenceTracker..."); _ = app.Services.GetRequiredService(); From 46a30c6b809a4b22e4208eff7e63e1262b85d0dd Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 14:22:03 +0100 Subject: [PATCH 09/10] refactor: Remove diagnostic logging from IrcGatewayService, Program, and ServerDirectoryService --- src/EchoHub.Server.Irc/IrcGatewayService.cs | 4 -- src/EchoHub.Server/Program.cs | 66 +------------------ .../Services/ServerDirectoryService.cs | 15 ----- 3 files changed, 1 insertion(+), 84 deletions(-) diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index 5515b9c..6f063a5 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -39,9 +39,7 @@ public sealed class IrcGatewayService : BackgroundService protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - Console.Error.WriteLine("[DIAG] IrcGatewayService.ExecuteAsync entered."); await Task.Yield(); - Console.Error.WriteLine($"[DIAG] IrcGatewayService.ExecuteAsync resumed after Task.Yield(). Enabled={_options.Enabled}"); if (!_options.Enabled) { @@ -149,10 +147,8 @@ public sealed class IrcGatewayService : BackgroundService public override async Task StopAsync(CancellationToken cancellationToken) { - Console.Error.WriteLine("[DIAG] IrcGatewayService.StopAsync entered."); // Cancel ExecuteAsync first so listeners stop accepting await base.StopAsync(cancellationToken); - Console.Error.WriteLine("[DIAG] IrcGatewayService.StopAsync: base.StopAsync returned."); // Force-close any remaining client connections foreach (var (_, conn) in _connections) diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 809355e..f5a38a9 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -169,9 +169,7 @@ while (true) await using var app = builder.Build(); // ── Database initialization ────────────────────────────────────────── - Console.Error.WriteLine("[DIAG] Database initialization starting..."); await DatabaseSetup.InitializeAsync(app.Services); - Console.Error.WriteLine("[DIAG] Database initialization complete."); // ── Middleware ──────────────────────────────────────────────────────── app.UseCors(); @@ -183,65 +181,7 @@ while (true) app.MapControllers(); app.MapHub(HubConstants.ChatHubPath); - // ── Diagnostic hooks ──────────────────────────────────────────────── - app.Lifetime.ApplicationStarted.Register( - () => Console.Error.WriteLine("[DIAG] ApplicationStarted fired")); - app.Lifetime.ApplicationStopping.Register( - () => Console.Error.WriteLine("[DIAG] ApplicationStopping fired")); - app.Lifetime.ApplicationStopped.Register( - () => Console.Error.WriteLine("[DIAG] ApplicationStopped fired")); - - // Heartbeat — proves the process is alive even if nothing else logs - var heartbeatCts = new CancellationTokenSource(); - _ = Task.Run(async () => - { - while (!heartbeatCts.Token.IsCancellationRequested) - { - await Task.Delay(5000, heartbeatCts.Token).ConfigureAwait(false); - Console.Error.WriteLine($"[DIAG] heartbeat {DateTimeOffset.UtcNow:HH:mm:ss}"); - } - }, heartbeatCts.Token); - - // ── Config diagnostics ───────────────────────────────────────────── - Console.Error.WriteLine($"[DIAG] Irc:Enabled = {app.Configuration.GetValue("Irc:Enabled")}"); - Console.Error.WriteLine($"[DIAG] Environment = {app.Environment.EnvironmentName}"); - - // ── Resolve singletons one-by-one to find which one hangs ──────── - Console.Error.WriteLine("[DIAG] Resolving PresenceTracker..."); - _ = app.Services.GetRequiredService(); - Console.Error.WriteLine("[DIAG] PresenceTracker OK."); - - Console.Error.WriteLine("[DIAG] Resolving JwtTokenService..."); - _ = app.Services.GetRequiredService(); - Console.Error.WriteLine("[DIAG] JwtTokenService OK."); - - Console.Error.WriteLine("[DIAG] Resolving FileStorageService..."); - _ = app.Services.GetRequiredService(); - Console.Error.WriteLine("[DIAG] FileStorageService OK."); - - Console.Error.WriteLine("[DIAG] Resolving IChatBroadcaster..."); - _ = app.Services.GetServices().ToList(); - Console.Error.WriteLine("[DIAG] IChatBroadcaster OK."); - - Console.Error.WriteLine("[DIAG] Resolving IChatService..."); - _ = app.Services.GetRequiredService(); - Console.Error.WriteLine("[DIAG] IChatService OK."); - - Console.Error.WriteLine("[DIAG] Resolving IHostedService instances..."); - var hostedServices = app.Services.GetServices().ToList(); - Console.Error.WriteLine($"[DIAG] Found {hostedServices.Count} hosted services:"); - foreach (var svc in hostedServices) - Console.Error.WriteLine($"[DIAG] - {svc.GetType().FullName}"); - Console.Error.Flush(); - - // ── Start with per-service timing ───────────────────────────────── - Console.Error.WriteLine("[DIAG] Calling app.StartAsync()..."); - await app.StartAsync(); - Console.Error.WriteLine("[DIAG] app.StartAsync() completed — server is running."); - - await app.WaitForShutdownAsync(); - Console.Error.WriteLine("[DIAG] WaitForShutdownAsync returned."); - heartbeatCts.Cancel(); + await app.RunAsync(); // Graceful shutdown (Ctrl+C) — exit the loop Log.Information("Server shut down gracefully"); @@ -249,8 +189,6 @@ while (true) } catch (Exception ex) { - Console.Error.WriteLine($"[DIAG] Top-level exception: {ex}"); - var uptime = DateTimeOffset.UtcNow - startTime; // If server ran for over 60 seconds, it's a runtime crash — reset failure count @@ -273,6 +211,4 @@ while (true) } } -Console.Error.WriteLine("[DIAG] Calling Log.CloseAndFlush()..."); Log.CloseAndFlush(); -Console.Error.WriteLine("[DIAG] Log.CloseAndFlush() done. Exiting process."); diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 69b44ee..75b3784 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -28,13 +28,10 @@ public sealed class ServerDirectoryService : BackgroundService protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - Console.Error.WriteLine("[DIAG] ServerDirectoryService.ExecuteAsync entered."); // Yield to let the host finish starting before we log or connect await Task.Yield(); - Console.Error.WriteLine("[DIAG] ServerDirectoryService.ExecuteAsync resumed after Task.Yield()."); var isPublic = _configuration.GetValue("Server:PublicServer"); - Console.Error.WriteLine($"[DIAG] ServerDirectoryService: PublicServer={isPublic}"); if (!isPublic) { _logger.LogInformation("PublicServer is disabled — not registering with directory"); @@ -42,7 +39,6 @@ public sealed class ServerDirectoryService : BackgroundService } var host = _configuration["Server:PublicHost"]; - Console.Error.WriteLine($"[DIAG] ServerDirectoryService: PublicHost={host}"); if (string.IsNullOrWhiteSpace(host)) { @@ -63,7 +59,6 @@ public sealed class ServerDirectoryService : BackgroundService try { - Console.Error.WriteLine("[DIAG] ServerDirectoryService: Building new connection, entering try block."); var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); connection.Reconnected += async _ => @@ -85,14 +80,9 @@ public sealed class ServerDirectoryService : BackgroundService }; // Connect with retry - Console.Error.WriteLine("[DIAG] ServerDirectoryService: Calling ConnectWithRetryAsync..."); if (!await ConnectWithRetryAsync(connection, stoppingToken)) - { - Console.Error.WriteLine("[DIAG] ServerDirectoryService: ConnectWithRetryAsync returned false (cancelled)."); return; - } - Console.Error.WriteLine("[DIAG] ServerDirectoryService: Connected successfully!"); _logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl); await RegisterAsync(serverName, description, host); @@ -209,25 +199,20 @@ public sealed class ServerDirectoryService : BackgroundService private static async Task DisposeConnectionAsync(HubConnection connection) { - Console.Error.WriteLine("[DIAG] ServerDirectoryService.DisposeConnectionAsync entered."); try { await connection.DisposeAsync() .AsTask().WaitAsync(TimeSpan.FromSeconds(3)); - Console.Error.WriteLine("[DIAG] ServerDirectoryService.DisposeConnectionAsync completed normally."); } catch { - Console.Error.WriteLine("[DIAG] ServerDirectoryService.DisposeConnectionAsync timed out or failed (3s)."); // Don't let a slow dispose block shutdown } } public override async Task StopAsync(CancellationToken cancellationToken) { - Console.Error.WriteLine("[DIAG] ServerDirectoryService.StopAsync entered."); await base.StopAsync(cancellationToken); - Console.Error.WriteLine("[DIAG] ServerDirectoryService.StopAsync: base.StopAsync returned."); _connection = null; } From 328821cd14ee4cb658d79a9c1bbbe6e485d14a0e Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 14:45:14 +0100 Subject: [PATCH 10/10] refactor: Update changelog for v0.2.2 to reflect startup and shutdown fixes, and standardize constructor injection --- docs/changelog/index.md | 2 +- docs/changelog/v0.2.2.md | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 3967dfa..a15eed2 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,7 +4,7 @@ Release history for EchoHub. ## Releases -- [v0.2.2](v0.2.2.md) - Shutdown Fix +- [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes - [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/v0.2.2.md b/docs/changelog/v0.2.2.md index 1c99749..b63c869 100644 --- a/docs/changelog/v0.2.2.md +++ b/docs/changelog/v0.2.2.md @@ -1,7 +1,14 @@ -# v0.2.2 - Shutdown Fix +# v0.2.2 - Startup & Shutdown Fixes ## 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 +- Fixed server hanging on startup when IRC gateway is enabled — circular DI dependency between `IrcGatewayService` → `IChatService` → `IChatBroadcaster` → `IrcBroadcaster` caused the DI container to deadlock +- Fixed `SignalRBroadcaster` eagerly resolving `IHubContext` during DI construction, which could deadlock on some platforms — now lazy-resolves via `IServiceProvider` on first use +- Simplified IRC service registration to use standard `AddSingleton` instead of manual factory, breaking the circular resolution chain +- 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 + +## Refactoring + +- Replaced primary constructors with standard constructor injection across all server classes for consistency