From 6db93ecfea2fa73cd5f3679cf40eef4e25c270ba Mon Sep 17 00:00:00 2001 From: HueByte Date: Wed, 22 Apr 2026 13:51:37 +0200 Subject: [PATCH] feat: enhance server registration handling with response envelope and error management --- .../Services/ServerDirectoryService.cs | 193 +++++++++++++----- 1 file changed, 138 insertions(+), 55 deletions(-) diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 5cb5dc4..72087eb 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Text.Json; using System.Threading.Channels; using Microsoft.AspNetCore.SignalR.Client; @@ -280,8 +281,8 @@ public sealed class ServerDirectoryService : BackgroundService // ClaimToken is null on first-ever registration; otherwise the token persisted on first claim. var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags, _claimStore.ClaimToken); - var result = await _connection.InvokeAsync("RegisterServer", dto); - await HandleRegistrationResultAsync(result, userCount, name, hosts); + var envelope = await _connection.InvokeAsync>("RegisterServer", dto); + await HandleRegistrationResponseAsync(envelope, userCount, name, hosts); } catch (Exception ex) { @@ -289,64 +290,49 @@ public sealed class ServerDirectoryService : BackgroundService } } - private async Task HandleRegistrationResultAsync(RegisterServerResult result, int userCount, string name, string[] hosts) + private async Task HandleRegistrationResponseAsync(Response? envelope, int userCount, string name, string[] hosts) { - if (!result.Success) + if (envelope is null) { _registrationPermanentlyFailed = true; - - var error = result.Error ?? "UnknownError"; - var conflicts = result.ConflictingHosts is { Length: > 0 } - ? string.Join(", ", result.ConflictingHosts) - : "(none reported)"; - - switch (error) - { - case DirectoryRegistrationErrors.HostAlreadyClaimed: - _logger.LogError( - "Directory rejected registration: host(s) already claimed by another server: {ConflictingHosts}. " + - "Change Server:PublicHosts or contact the directory admin to release the claim. Server will not retry until restarted.", - conflicts); - break; - - case DirectoryRegistrationErrors.InvalidToken: - _logger.LogError( - "Directory rejected registration: persisted claim token is invalid (likely deleted by admin or stale). " + - "Delete the claim file ({ClaimFile}) to claim fresh, or contact the directory admin. Server will not retry until restarted.", - _claimStore.FilePath); - break; - - case DirectoryRegistrationErrors.HostConflict: - _logger.LogError( - "Directory rejected registration: token is valid but newly-advertised host(s) conflict with another server's row: {ConflictingHosts}. " + - "Remove the conflicting entries from Server:PublicHosts. Server will not retry until restarted.", - conflicts); - break; - - default: - _logger.LogError("Directory rejected registration with unknown error code: {Error}. Server will not retry until restarted.", error); - break; - } - - _claimStore.SetFailure(error, result.ConflictingHosts); + _logger.LogError("Directory returned a null envelope for RegisterServer — treating as malformed. Server will not retry until restarted."); + _claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null); return; } - // Success path - if (!result.ServerId.HasValue) + // Pin protocol version. Spec: fail hard on mismatch — bumps are coordinated. + if (!string.Equals(envelope.Version, DirectoryProtocol.Version, StringComparison.Ordinal)) { - _logger.LogWarning("Directory registration succeeded but ServerId was missing — treating as failure to be safe."); _registrationPermanentlyFailed = true; - _claimStore.SetFailure("MissingServerId", null); + _logger.LogError( + "Directory protocol version mismatch: client expects {Expected}, hub returned {Actual}. " + + "Refusing to operate. Coordinate a deploy that aligns both sides.", + DirectoryProtocol.Version, envelope.Version ?? "(null)"); + _claimStore.SetFailure(DirectoryRegistrationErrors.ProtocolVersionMismatch, null); return; } - var serverId = result.ServerId.Value; - - // Persist a freshly-issued claim token *before* anything else acks success — this is our durability guarantee. - if (!string.IsNullOrEmpty(result.ClaimToken)) + if (!envelope.IsSuccess) { - await _claimStore.SaveClaimAsync(result.ClaimToken, serverId); + await HandleRegistrationErrorAsync(envelope.Errors); + return; + } + + if (envelope.Data is null) + { + _registrationPermanentlyFailed = true; + _logger.LogError("Directory returned IsSuccess=true but Data was null — treating as malformed. Server will not retry until restarted."); + _claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null); + return; + } + + var data = envelope.Data; + var serverId = data.ServerId; + + // Persist a freshly-issued claim token *before* anything else acks success — durability guarantee for first claim. + if (!string.IsNullOrEmpty(data.ClaimToken)) + { + await _claimStore.SaveClaimAsync(data.ClaimToken, serverId); } else { @@ -359,6 +345,84 @@ public sealed class ServerDirectoryService : BackgroundService _logger.LogInformation("Registered with directory as {Name} at {Hosts} (ServerId {ServerId})", name, string.Join(", ", hosts), serverId); } + private Task HandleRegistrationErrorAsync(ErrorDetail[]? errors) + { + _registrationPermanentlyFailed = true; + + var firstError = errors is { Length: > 0 } ? errors[0] : null; + var code = firstError?.Code ?? "UnknownError"; + var conflictingHosts = ExtractConflictingHosts(firstError); + var conflicts = conflictingHosts is { Length: > 0 } + ? string.Join(", ", conflictingHosts) + : "(none reported)"; + + switch (code) + { + case DirectoryRegistrationErrors.HostAlreadyClaimed: + _logger.LogError( + "Directory rejected registration: host(s) already claimed by another server: {ConflictingHosts}. " + + "Change Server:PublicHosts or contact the directory admin to release the claim. Server will not retry until restarted.", + conflicts); + break; + + case DirectoryRegistrationErrors.InvalidToken: + _logger.LogError( + "Directory rejected registration: persisted claim token is invalid (likely deleted by admin or stale). " + + "Delete the claim file ({ClaimFile}) to claim fresh, or contact the directory admin. Server will not retry until restarted.", + _claimStore.FilePath); + break; + + case DirectoryRegistrationErrors.HostConflict: + _logger.LogError( + "Directory rejected registration: token is valid but newly-advertised host(s) conflict with another server's row: {ConflictingHosts}. " + + "Remove the conflicting entries from Server:PublicHosts. Server will not retry until restarted.", + conflicts); + break; + + case DirectoryRegistrationErrors.InvalidInput: + _logger.LogError( + "Directory rejected registration as InvalidInput ({Message}). Likely a client/hub contract drift — check Server config. Server will not retry until restarted.", + firstError?.Message ?? "(no message)"); + break; + + default: + _logger.LogError( + "Directory rejected registration with unknown error code: {Error} ({Message}). Server will not retry until restarted.", + code, firstError?.Message ?? "(no message)"); + break; + } + + _claimStore.SetFailure(code, conflictingHosts); + return Task.CompletedTask; + } + + /// + /// Pulls ConflictingHosts out of an error's loosely-typed Data payload. + /// Tolerates both PascalCase and camelCase keys since SignalR's wire casing depends on + /// the hub's serializer config and the field is typed object?. + /// + private static string[]? ExtractConflictingHosts(ErrorDetail? error) + { + if (error?.Data is not JsonElement element || element.ValueKind != JsonValueKind.Object) + return null; + + if (!element.TryGetProperty("ConflictingHosts", out var hostsProp) + && !element.TryGetProperty("conflictingHosts", out hostsProp)) + return null; + + if (hostsProp.ValueKind != JsonValueKind.Array) + return null; + + List hosts = []; + foreach (var item in hostsProp.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s) + hosts.Add(s); + } + + return hosts.Count == 0 ? null : hosts.ToArray(); + } + private static string ResolveVersion() { var assembly = typeof(ServerDirectoryService).Assembly; @@ -414,16 +478,35 @@ internal record RegisterServerDto( string[] Tags, string? ClaimToken); -internal record RegisterServerResult( - bool Success, - Guid? ServerId, - string? ClaimToken, - string? Error, - string[]? ConflictingHosts); +internal record RegisterServerResult(Guid ServerId, string? ClaimToken); + +/// +/// Envelope wrapping every directory hub response. Mirrors the EchoHubSpace contract. +/// +internal record Response(bool IsSuccess, T? Data, ErrorDetail[]? Errors, string? Version); + +/// +/// Error entry inside a . Data is loosely-typed because the +/// payload shape varies by error code (e.g. { ConflictingHosts: string[] } for host errors). +/// +internal record ErrorDetail(string Code, string? Message, JsonElement? Data); + +internal static class DirectoryProtocol +{ + /// + /// Pinned envelope protocol version. Bumps are coordinated across both repos. + /// + public const string Version = "1.0"; +} internal static class DirectoryRegistrationErrors { - public const string HostAlreadyClaimed = "HostAlreadyClaimed"; + public const string InvalidInput = "InvalidInput"; public const string InvalidToken = "InvalidToken"; + public const string HostAlreadyClaimed = "HostAlreadyClaimed"; public const string HostConflict = "HostConflict"; + + // Client-side synthetic codes (never returned by hub, generated locally for status reporting) + public const string ProtocolVersionMismatch = "ProtocolVersionMismatch"; + public const string MalformedResponse = "MalformedResponse"; }