diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index f97278d..b00e38a 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -10,10 +10,18 @@ public class PresenceTracker private readonly object _lock = new(); + /// + /// Raised when the distinct online user count changes (multi-connection users only fire once). + /// + public event Action? UserCountChanged; + public void UserConnected(string connectionId, Guid userId, string username) { _connections[connectionId] = (userId, username); + bool userIsNew; + int newCount; + // Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside. // It also makes the TryGetValue → add sequence atomic to prevent race conditions. lock (_lock) @@ -22,10 +30,19 @@ public class PresenceTracker { connections = new HashSet(); _userConnections[username] = connections; + userIsNew = true; + } + else + { + userIsNew = false; } connections.Add(connectionId); + newCount = _userConnections.Count; } + + if (userIsNew) + UserCountChanged?.Invoke(newCount); } public string? UserDisconnected(string connectionId) @@ -34,6 +51,8 @@ public class PresenceTracker return null; var username = userInfo.username; + bool userRemoved = false; + int newCount; lock (_lock) { @@ -45,10 +64,16 @@ public class PresenceTracker { _userConnections.TryRemove(username, out _); _userChannels.TryRemove(username, out _); + userRemoved = true; } } + + newCount = _userConnections.Count; } + if (userRemoved) + UserCountChanged?.Invoke(newCount); + return username; } @@ -160,20 +185,29 @@ public class PresenceTracker /// public (List ConnectionIds, List Channels) ForceRemoveUser(string username) { + bool userRemoved; + int newCount; + List channels; + List connectionIds; + lock (_lock) { - var channels = _userChannels.TryRemove(username, out var ch) + channels = _userChannels.TryRemove(username, out var ch) ? ch.ToList() : []; - var connectionIds = _userConnections.TryRemove(username, out var conns) - ? conns.ToList() - : []; + userRemoved = _userConnections.TryRemove(username, out var conns); + connectionIds = userRemoved ? conns!.ToList() : []; foreach (var connId in connectionIds) _connections.TryRemove(connId, out _); - return (connectionIds, channels); + newCount = _userConnections.Count; } + + if (userRemoved) + UserCountChanged?.Invoke(newCount); + + return (connectionIds, channels); } } diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 521f619..3753bde 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -1,3 +1,5 @@ +using System.Reflection; +using System.Threading.Channels; using Microsoft.AspNetCore.SignalR.Client; namespace EchoHub.Server.Services; @@ -5,14 +7,18 @@ namespace EchoHub.Server.Services; public sealed class ServerDirectoryService : BackgroundService { private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers"; - private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30); private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2); private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30); + private static readonly TimeSpan UserCountMinInterval = TimeSpan.FromSeconds(1); private readonly IConfiguration _configuration; private readonly PresenceTracker _presenceTracker; private readonly ILogger _logger; + // Single-slot, latest-wins channel coalesces bursts of presence changes into one update. + private readonly Channel _userCountUpdates = Channel.CreateBounded( + new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest }); + private HubConnection? _connection; private int _lastReportedUserCount = -1; @@ -38,19 +44,44 @@ public sealed class ServerDirectoryService : BackgroundService return; } - var host = _configuration["Server:PublicHost"]; + var hosts = _configuration.GetSection("Server:PublicHosts").Get() + ?.Where(h => !string.IsNullOrWhiteSpace(h)) + .ToArray() ?? Array.Empty(); - if (string.IsNullOrWhiteSpace(host)) + if (hosts.Length == 0) { - _logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration"); + _logger.LogWarning("PublicServer is enabled but Server:PublicHosts is empty — skipping directory registration"); return; } var serverName = _configuration["Server:Name"] ?? "EchoHub Server"; var description = _configuration["Server:Description"]; + var tags = _configuration.GetSection("Server:Tags").Get() + ?.Where(t => !string.IsNullOrWhiteSpace(t)) + .ToArray() ?? Array.Empty(); + var version = ResolveVersion(); - _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} ({Hosts})", serverName, string.Join(", ", hosts)); + _presenceTracker.UserCountChanged += OnUserCountChanged; + try + { + await RunConnectionLoopAsync(serverName, description, hosts, version, tags, stoppingToken); + } + finally + { + _presenceTracker.UserCountChanged -= OnUserCountChanged; + } + } + + private async Task RunConnectionLoopAsync( + string serverName, + string? description, + string[] hosts, + string version, + string[] tags, + CancellationToken stoppingToken) + { // Outer loop: rebuilds the connection if automatic reconnect permanently fails while (!stoppingToken.IsCancellationRequested) { @@ -78,7 +109,7 @@ public sealed class ServerDirectoryService : BackgroundService { _logger.LogInformation("Reconnected to directory — re-registering server"); _lastReportedUserCount = -1; - await RegisterAsync(serverName, description, host); + await RegisterAsync(serverName, description, hosts, version, tags); }; connection.Closed += ex => @@ -97,10 +128,10 @@ public sealed class ServerDirectoryService : BackgroundService return; _logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl); - await RegisterAsync(serverName, description, host); + await RegisterAsync(serverName, description, hosts, version, tags); - // Poll user count until the connection is permanently closed or cancellation - await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken); + // Push user-count updates as PresenceTracker raises events, until the connection closes or cancellation + await ProcessUserCountUpdatesAsync(connection, connectionPermanentlyClosed.Task, stoppingToken); if (stoppingToken.IsCancellationRequested) return; @@ -151,33 +182,58 @@ public sealed class ServerDirectoryService : BackgroundService return false; } - private async Task PollUserCountAsync(HubConnection connection, Task connectionClosed, CancellationToken ct) + private void OnUserCountChanged(int newCount) { + // Single-slot channel: latest write wins, so a burst of presence changes coalesces. + _userCountUpdates.Writer.TryWrite(newCount); + } + + private async Task ProcessUserCountUpdatesAsync(HubConnection connection, Task connectionClosed, CancellationToken ct) + { + var lastSentAt = DateTimeOffset.MinValue; + while (!ct.IsCancellationRequested) { - var delayTask = Task.Delay(UpdateInterval, ct); - var completed = await Task.WhenAny(delayTask, connectionClosed); + var waitTask = _userCountUpdates.Reader.WaitToReadAsync(ct).AsTask(); + var completed = await Task.WhenAny(waitTask, connectionClosed); if (completed == connectionClosed) return; - // Observe the delay task (may throw if cancelled) - try { await delayTask; } + bool hasUpdate; + try { hasUpdate = await waitTask; } catch (OperationCanceledException) { return; } + if (!hasUpdate) + return; + + if (!_userCountUpdates.Reader.TryRead(out var count)) + continue; + + // Throttle: enforce a minimum interval between sends. While we wait, drain newer + // values so the eventual send carries the latest count, not a stale snapshot. + var elapsed = DateTimeOffset.UtcNow - lastSentAt; + if (elapsed < UserCountMinInterval) + { + try { await Task.Delay(UserCountMinInterval - elapsed, ct); } + catch (OperationCanceledException) { return; } + + while (_userCountUpdates.Reader.TryRead(out var newer)) + count = newer; + } + + if (count == _lastReportedUserCount) + continue; + if (connection.State != HubConnectionState.Connected) continue; - var currentCount = _presenceTracker.GetOnlineUserCount(); - - if (currentCount == _lastReportedUserCount) - continue; - try { - await connection.InvokeAsync("UpdateUserCount", currentCount, ct); - _lastReportedUserCount = currentCount; - _logger.LogDebug("Updated directory user count to {Count}", currentCount); + await connection.InvokeAsync("UpdateUserCount", count, ct); + _lastReportedUserCount = count; + lastSentAt = DateTimeOffset.UtcNow; + _logger.LogDebug("Updated directory user count to {Count}", count); } catch (Exception ex) { @@ -192,7 +248,7 @@ public sealed class ServerDirectoryService : BackgroundService return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay; } - private async Task RegisterAsync(string name, string? description, string host) + private async Task RegisterAsync(string name, string? description, string[] hosts, string version, string[] tags) { if (_connection?.State != HubConnectionState.Connected) return; @@ -200,10 +256,10 @@ public sealed class ServerDirectoryService : BackgroundService try { var userCount = _presenceTracker.GetOnlineUserCount(); - var dto = new RegisterServerDto(name, description, host, userCount); + var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags); 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 {Hosts}", name, string.Join(", ", hosts)); } catch (Exception ex) { @@ -211,6 +267,20 @@ public sealed class ServerDirectoryService : BackgroundService } } + private static string ResolveVersion() + { + var assembly = typeof(ServerDirectoryService).Assembly; + var informational = assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(informational)) + { + // Strip git SHA suffix that SourceLink appends (e.g. "0.2.10+abc123") + var plus = informational.IndexOf('+'); + return plus >= 0 ? informational[..plus] : informational; + } + + return assembly.GetName().Version?.ToString() ?? "0.0.0"; + } + private static async Task DisposeConnectionAsync(HubConnection connection) { try @@ -243,4 +313,10 @@ public sealed class ServerDirectoryService : BackgroundService } } -internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount); +internal record RegisterServerDto( + string Name, + string? Description, + string[] Hosts, + int UserCount, + string Version, + string[] Tags); diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index 5d21c45..2db3c11 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -12,7 +12,8 @@ "Name": "My EchoHub Server", "Description": "A self-hosted EchoHub chat server", "PublicServer": false, - "PublicHost": "", + "PublicHosts": [], + "Tags": [], "Admins": [] }, "Storage": {